2709 lines
88 KiB
Go
2709 lines
88 KiB
Go
package promptkit_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/promptkit"
|
|
)
|
|
|
|
const (
|
|
frameworkContractRoot = "./testdata/framework"
|
|
frameworkPromptDir = frameworkContractRoot + "/prompts"
|
|
frameworkProfileDir = frameworkContractRoot + "/profiles"
|
|
frameworkSchemaDir = frameworkContractRoot + "/schemas"
|
|
|
|
frameworkMarkdownSummaryPromptID = "contract.markdown_summary"
|
|
frameworkStructuredEventsPromptID = "contract.structured_events"
|
|
frameworkFastProfileID = "contract-fast"
|
|
frameworkQualityProfileID = "contract-quality"
|
|
|
|
frameworkTranscriptPath = frameworkContractRoot + "/fixtures/transcript.md"
|
|
frameworkGlossaryPath = frameworkContractRoot + "/fixtures/glossary.yml"
|
|
)
|
|
|
|
func TestNewEngineRejectsMissingPromptDir(t *testing.T) {
|
|
_, err := promptkit.NewEngine(promptkit.Config{ProfileDir: frameworkProfileDir})
|
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewEngineAcceptsMissingProfileDir(t *testing.T) {
|
|
_, err := promptkit.NewEngine(promptkit.Config{PromptDir: frameworkPromptDir})
|
|
if err != nil {
|
|
t.Fatalf("expected missing profile dir to use built-ins, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepareWorksWithFrameworkContractCorpus(t *testing.T) {
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: frameworkProfileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct engine from framework contract corpus: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
promptID string
|
|
profileID string
|
|
model string
|
|
structured bool
|
|
}{
|
|
{
|
|
name: "markdown summary",
|
|
promptID: frameworkMarkdownSummaryPromptID,
|
|
profileID: frameworkFastProfileID,
|
|
model: "contract-fast-model",
|
|
},
|
|
{
|
|
name: "structured events",
|
|
promptID: frameworkStructuredEventsPromptID,
|
|
profileID: frameworkQualityProfileID,
|
|
model: "contract-quality-model",
|
|
structured: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: tt.promptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.File(frameworkTranscriptPath),
|
|
"glossary": promptkit.File(frameworkGlossaryPath),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("prepare framework contract prompt: %v", err)
|
|
}
|
|
if prepared.PromptID != tt.promptID {
|
|
t.Fatalf("unexpected prompt id: got %q, want %q", prepared.PromptID, tt.promptID)
|
|
}
|
|
if prepared.SelectedProfileID != tt.profileID {
|
|
t.Fatalf("unexpected selected profile: got %q, want %q", prepared.SelectedProfileID, tt.profileID)
|
|
}
|
|
if prepared.EffectiveModelParams.Model != tt.model {
|
|
t.Fatalf("unexpected effective model: got %q, want %q", prepared.EffectiveModelParams.Model, tt.model)
|
|
}
|
|
if len(prepared.Messages) != 2 {
|
|
t.Fatalf("expected rendered messages, got %d", len(prepared.Messages))
|
|
}
|
|
if !strings.Contains(prepared.Messages[1].Content, "Nia labels the archive.") {
|
|
t.Fatalf("expected relative prompt content to render the transcript, got %q", prepared.Messages[1].Content)
|
|
}
|
|
if prepared.InputHashes["transcript"] == "" || prepared.InputHashes["glossary"] == "" {
|
|
t.Fatalf("expected input hashes, got %#v", prepared.InputHashes)
|
|
}
|
|
|
|
if !tt.structured {
|
|
if prepared.StructuredOutput != nil {
|
|
t.Fatalf("expected no structured output specification, got %#v", prepared.StructuredOutput)
|
|
}
|
|
return
|
|
}
|
|
|
|
if prepared.StructuredOutput == nil || prepared.StructuredOutput.JSONSchema == nil {
|
|
t.Fatalf("expected loaded JSON Schema structured output, got %#v", prepared.StructuredOutput)
|
|
}
|
|
schema, ok := prepared.StructuredOutput.JSONSchema.Schema.(map[string]any)
|
|
if !ok || schema["type"] != "object" {
|
|
t.Fatalf("expected loaded object JSON Schema, got %#v", prepared.StructuredOutput.JSONSchema.Schema)
|
|
}
|
|
properties, ok := schema["properties"].(map[string]any)
|
|
if !ok || properties["events"] == nil {
|
|
t.Fatalf("expected loaded events schema property, got %#v", schema)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrepareWorksWithInlineInputs(t *testing.T) {
|
|
engine := newContractEngine(t)
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin scouts the tower.\nKara lights a lantern."),
|
|
"glossary": promptkit.InlineWithURI("memory://glossary.yml", "party:\n - Rin\n - Kara\n"),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected prepare to succeed, got %v", err)
|
|
}
|
|
if len(prepared.Messages) != 2 {
|
|
t.Fatalf("expected rendered messages, got %d", len(prepared.Messages))
|
|
}
|
|
rendered := prepared.Messages[1].Content
|
|
if !strings.Contains(rendered, "Rin scouts the tower.") || !strings.Contains(rendered, "party:") {
|
|
t.Fatalf("expected inline inputs in rendered prompt, got %q", rendered)
|
|
}
|
|
}
|
|
|
|
func TestPreparedRunJSONDoesNotExposeSecretOrTargetPresence(t *testing.T) {
|
|
const envName = "PROMPTKIT_API_KEY"
|
|
const secret = "public-api-test-secret"
|
|
t.Setenv(envName, secret)
|
|
|
|
profileDir := t.TempDir()
|
|
writePublicProfileFileWithAPIKeyEnv(t, profileDir, "prepared-secret", "http://localhost:8000/v1", "prepared-secret-model", envName)
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: profileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkStructuredEventsPromptID,
|
|
ProfileID: "prepared-secret",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.File(frameworkTranscriptPath),
|
|
"glossary": promptkit.File(frameworkGlossaryPath),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected prepare to succeed, got %v", err)
|
|
}
|
|
|
|
payload, err := json.Marshal(prepared)
|
|
if err != nil {
|
|
t.Fatalf("expected prepared run to marshal, got %v", err)
|
|
}
|
|
out := string(payload)
|
|
if strings.Contains(out, secret) {
|
|
t.Fatalf("prepared run JSON leaked raw API key value: %s", out)
|
|
}
|
|
if !strings.Contains(out, envName) {
|
|
t.Fatalf("prepared run JSON should retain api_key_env name, got %s", out)
|
|
}
|
|
for _, forbidden := range []string{"TargetPresence", "target_presence"} {
|
|
if strings.Contains(out, forbidden) {
|
|
t.Fatalf("prepared run JSON exposed internal target presence metadata %q: %s", forbidden, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunRequestFormattingRedactsDirectAPIKey(t *testing.T) {
|
|
const secret = "run-request-secret"
|
|
req := promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: frameworkFastProfileID,
|
|
APIKey: secret,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
},
|
|
}
|
|
|
|
for _, formatted := range []string{
|
|
fmt.Sprint(req),
|
|
fmt.Sprintf("%+v", req),
|
|
fmt.Sprintf("%#v", req),
|
|
} {
|
|
if strings.Contains(formatted, secret) {
|
|
t.Fatalf("formatted RunRequest leaked API key: %s", formatted)
|
|
}
|
|
if !strings.Contains(formatted, "APIKeySet:true") {
|
|
t.Fatalf("formatted RunRequest should indicate an API key is set, got %s", formatted)
|
|
}
|
|
}
|
|
|
|
payload, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatalf("expected RunRequest to marshal, got %v", err)
|
|
}
|
|
if strings.Contains(string(payload), secret) {
|
|
t.Fatalf("RunRequest JSON leaked API key: %s", payload)
|
|
}
|
|
}
|
|
|
|
func TestGenerateRequestFormattingRedactsDirectAPIKey(t *testing.T) {
|
|
const secret = "generate-request-secret"
|
|
req := promptkit.GenerateRequest{
|
|
Prompt: promptkit.RenderedPrompt{Messages: []promptkit.RenderedMessage{
|
|
{Role: "user", Content: "secret prompt content"},
|
|
}},
|
|
Target: promptkit.ExecutionTarget{
|
|
Model: "test-model",
|
|
ExtraParams: map[string]any{
|
|
"provider_option": "on",
|
|
},
|
|
},
|
|
APIKey: secret,
|
|
}
|
|
|
|
for _, formatted := range []string{
|
|
fmt.Sprint(req),
|
|
fmt.Sprintf("%+v", req),
|
|
fmt.Sprintf("%#v", req),
|
|
} {
|
|
if strings.Contains(formatted, secret) {
|
|
t.Fatalf("formatted GenerateRequest leaked API key: %s", formatted)
|
|
}
|
|
if strings.Contains(formatted, "secret prompt content") {
|
|
t.Fatalf("formatted GenerateRequest leaked prompt content: %s", formatted)
|
|
}
|
|
if !strings.Contains(formatted, "APIKeySet:true") {
|
|
t.Fatalf("formatted GenerateRequest should indicate an API key is set, got %s", formatted)
|
|
}
|
|
}
|
|
|
|
payload, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatalf("expected GenerateRequest to marshal, got %v", err)
|
|
}
|
|
if strings.Contains(string(payload), secret) {
|
|
t.Fatalf("GenerateRequest JSON leaked API key: %s", payload)
|
|
}
|
|
}
|
|
|
|
func TestEngineExecutionSettingPrecedence(t *testing.T) {
|
|
floatPointer := func(value float64) *float64 {
|
|
return &value
|
|
}
|
|
intPointer := func(value int) *int {
|
|
return &value
|
|
}
|
|
stringPointer := func(value string) *string {
|
|
return &value
|
|
}
|
|
|
|
defaultsProfile := executionProfileFixture{
|
|
id: "settings-defaults",
|
|
endpoint: "http://profile-defaults.test/v1",
|
|
model: "profile-defaults-model",
|
|
serviceTier: "profile-defaults-tier",
|
|
reasoningEffort: "profile-defaults-reasoning",
|
|
apiKeyEnv: "PROMPTKIT_PRECEDENCE_DEFAULTS",
|
|
extraParamSource: "profile-defaults",
|
|
}
|
|
profileSettings := executionProfileFixture{
|
|
id: "settings-profile",
|
|
endpoint: "http://profile-settings.test/v1",
|
|
model: "profile-settings-model",
|
|
temperature: 0.31,
|
|
maxTokens: 311,
|
|
topP: 0.61,
|
|
timeoutSeconds: 71,
|
|
serviceTier: "profile-settings-tier",
|
|
reasoningEffort: "profile-settings-reasoning",
|
|
apiKeyEnv: "PROMPTKIT_PRECEDENCE_PROFILE",
|
|
extraParamSource: "profile-settings",
|
|
}
|
|
requestProfile := executionProfileFixture{
|
|
id: "settings-request",
|
|
endpoint: "http://profile-request.test/v1",
|
|
model: "profile-request-model",
|
|
temperature: 0.29,
|
|
maxTokens: 299,
|
|
topP: 0.59,
|
|
timeoutSeconds: 79,
|
|
serviceTier: "profile-request-tier",
|
|
reasoningEffort: "profile-request-reasoning",
|
|
apiKeyEnv: "PROMPTKIT_PRECEDENCE_REQUEST_PROFILE",
|
|
extraParamSource: "profile-request",
|
|
}
|
|
zeroOverrideProfile := executionProfileFixture{
|
|
id: "settings-zero",
|
|
endpoint: "http://profile-zero.test/v1",
|
|
model: "profile-zero-model",
|
|
temperature: 0.43,
|
|
maxTokens: 433,
|
|
topP: 0.73,
|
|
timeoutSeconds: 83,
|
|
serviceTier: "profile-zero-tier",
|
|
reasoningEffort: "profile-zero-reasoning",
|
|
apiKeyEnv: "PROMPTKIT_PRECEDENCE_ZERO",
|
|
extraParamSource: "profile-zero",
|
|
}
|
|
|
|
requestTarget := promptkit.ExecutionTarget{
|
|
Endpoint: "http://request-settings.test/v1",
|
|
Model: "request-settings-model",
|
|
Temperature: 0.87,
|
|
MaxTokens: 877,
|
|
TopP: 0.97,
|
|
TimeoutSeconds: 177,
|
|
ServiceTier: "request-settings-tier",
|
|
ReasoningEffort: "request-settings-reasoning",
|
|
APIKeyEnv: "PROMPTKIT_PRECEDENCE_REQUEST",
|
|
ExtraParams: map[string]any{"source": "request-settings"},
|
|
}
|
|
zeroOverrideTarget := executionTargetFromProfileFixture(zeroOverrideProfile)
|
|
zeroOverrideTarget.Temperature = 0
|
|
zeroOverrideTarget.MaxTokens = 0
|
|
zeroOverrideTarget.TopP = 0
|
|
zeroOverrideTarget.TimeoutSeconds = 0
|
|
|
|
tests := []struct {
|
|
name string
|
|
profile executionProfileFixture
|
|
override *promptkit.ExecutionTargetOverride
|
|
want promptkit.ExecutionTarget
|
|
wantPresence promptkit.ExecutionTargetPresence
|
|
}{
|
|
{
|
|
name: "unspecified provider controls retain framework timeout",
|
|
profile: defaultsProfile,
|
|
want: promptkit.ExecutionTarget{
|
|
Endpoint: defaultsProfile.endpoint,
|
|
Model: defaultsProfile.model,
|
|
Temperature: 0,
|
|
MaxTokens: 0,
|
|
TopP: 0,
|
|
TimeoutSeconds: 600,
|
|
ServiceTier: defaultsProfile.serviceTier,
|
|
ReasoningEffort: defaultsProfile.reasoningEffort,
|
|
APIKeyEnv: defaultsProfile.apiKeyEnv,
|
|
ExtraParams: map[string]any{"source": defaultsProfile.extraParamSource},
|
|
},
|
|
},
|
|
{
|
|
name: "profile settings replace framework defaults",
|
|
profile: profileSettings,
|
|
want: executionTargetFromProfileFixture(profileSettings),
|
|
},
|
|
{
|
|
name: "request settings replace profile settings",
|
|
profile: requestProfile,
|
|
override: &promptkit.ExecutionTargetOverride{
|
|
Endpoint: requestTarget.Endpoint,
|
|
Model: requestTarget.Model,
|
|
Temperature: floatPointer(requestTarget.Temperature),
|
|
MaxTokens: intPointer(requestTarget.MaxTokens),
|
|
TopP: floatPointer(requestTarget.TopP),
|
|
TimeoutSeconds: intPointer(requestTarget.TimeoutSeconds),
|
|
ServiceTier: requestTarget.ServiceTier,
|
|
ReasoningEffort: stringPointer(requestTarget.ReasoningEffort),
|
|
APIKeyEnv: requestTarget.APIKeyEnv,
|
|
ExtraParams: requestTarget.ExtraParams,
|
|
},
|
|
want: requestTarget,
|
|
wantPresence: promptkit.ExecutionTargetPresence{Temperature: true, MaxTokens: true, TopP: true, TimeoutSeconds: true},
|
|
},
|
|
{
|
|
name: "explicit request zero replaces profile settings",
|
|
profile: zeroOverrideProfile,
|
|
override: &promptkit.ExecutionTargetOverride{
|
|
Temperature: floatPointer(0),
|
|
MaxTokens: intPointer(0),
|
|
TopP: floatPointer(0),
|
|
TimeoutSeconds: intPointer(0),
|
|
},
|
|
want: zeroOverrideTarget,
|
|
wantPresence: promptkit.ExecutionTargetPresence{Temperature: true, MaxTokens: true, TopP: true, TimeoutSeconds: true},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Setenv(tt.profile.apiKeyEnv, "set")
|
|
if tt.override != nil && tt.override.APIKeyEnv != "" {
|
|
t.Setenv(tt.override.APIKeyEnv, "set")
|
|
}
|
|
|
|
profileDir := t.TempDir()
|
|
writeExecutionProfileFixture(t, profileDir, tt.profile)
|
|
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: profileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithLLMClient(fake))
|
|
if err != nil {
|
|
t.Fatalf("construct engine: %v", err)
|
|
}
|
|
|
|
_, err = engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: tt.profile.id,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Nia labels the archive."),
|
|
"glossary": promptkit.Inline("archive: A catalogued collection."),
|
|
},
|
|
Execution: tt.override,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run engine: %v", err)
|
|
}
|
|
if len(fake.requests) != 1 {
|
|
t.Fatalf("expected one generation request, got %d", len(fake.requests))
|
|
}
|
|
got := fake.requests[0]
|
|
if !reflect.DeepEqual(got.Target, tt.want) {
|
|
t.Fatalf("unexpected effective target:\ngot=%#v\nwant=%#v", got.Target, tt.want)
|
|
}
|
|
if got.TargetPresence != tt.wantPresence {
|
|
t.Fatalf("unexpected target presence: got=%+v want=%+v", got.TargetPresence, tt.wantPresence)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("blank request reasoning clears profile setting", func(t *testing.T) {
|
|
profile := executionProfileFixture{
|
|
id: "settings-reasoning-clear",
|
|
endpoint: "http://profile-reasoning.test/v1",
|
|
model: "profile-reasoning-model",
|
|
reasoningEffort: "medium",
|
|
}
|
|
profileDir := t.TempDir()
|
|
writeExecutionProfileFixture(t, profileDir, profile)
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: profileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct engine: %v", err)
|
|
}
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: profile.id,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Nia labels the archive."),
|
|
"glossary": promptkit.Inline("archive: A catalogued collection."),
|
|
},
|
|
Execution: &promptkit.ExecutionTargetOverride{
|
|
ReasoningEffort: stringPointer(" \t "),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("prepare engine: %v", err)
|
|
}
|
|
if prepared.EffectiveModelParams.ReasoningEffort != "" {
|
|
t.Fatalf("expected blank request reasoning to clear profile value, got %q", prepared.EffectiveModelParams.ReasoningEffort)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRunSucceedsWithInjectedLLMClient(t *testing.T) {
|
|
const envName = "PROMPTKIT_API_KEY"
|
|
const secret = "run-secret-value"
|
|
t.Setenv(envName, secret)
|
|
|
|
fake := &fakeLLMClient{
|
|
response: &promptkit.GenerateResponse{
|
|
Content: "# Summary\n\nDone.",
|
|
Usage: promptkit.TokenUsage{
|
|
PromptTokens: 10,
|
|
CompletionTokens: 5,
|
|
TotalTokens: 15,
|
|
CachedTokens: 3,
|
|
CacheWriteTokens: 2,
|
|
},
|
|
},
|
|
}
|
|
engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(fake))
|
|
|
|
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
Execution: &promptkit.ExecutionTargetOverride{
|
|
APIKeyEnv: envName,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run to succeed, got %v", err)
|
|
}
|
|
if result.RunID == "" {
|
|
t.Fatalf("expected run id")
|
|
}
|
|
if result.RawOutput != fake.response.Content {
|
|
t.Fatalf("unexpected raw output: %q", result.RawOutput)
|
|
}
|
|
if string(result.Artifact.Body) != fake.response.Content {
|
|
t.Fatalf("unexpected artifact body: %q", string(result.Artifact.Body))
|
|
}
|
|
if result.Artifact.ContentType != "text/markdown" {
|
|
t.Fatalf("unexpected artifact content type: %q", result.Artifact.ContentType)
|
|
}
|
|
if result.Validation.Status != promptkit.ValidationPassed || !result.Validation.IsValid {
|
|
t.Fatalf("expected passed validation, got %+v", result.Validation)
|
|
}
|
|
if result.PromptID != frameworkMarkdownSummaryPromptID || result.SelectedProfileID != frameworkFastProfileID || result.ModelName != "contract-fast-model" {
|
|
t.Fatalf("unexpected run metadata: %+v", result)
|
|
}
|
|
if result.Usage.TotalTokens != 15 || result.Usage.CachedTokens != 3 || result.Usage.CacheWriteTokens != 2 {
|
|
t.Fatalf("unexpected usage: %+v", result.Usage)
|
|
}
|
|
|
|
payload, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatalf("expected run result to marshal, got %v", err)
|
|
}
|
|
if strings.Contains(string(payload), secret) {
|
|
t.Fatalf("run result JSON leaked raw API key value: %s", payload)
|
|
}
|
|
}
|
|
|
|
func TestEngineRunWithDirectorySourcesAndFileInputs(t *testing.T) {
|
|
fake := &fakeLLMClient{
|
|
response: &promptkit.GenerateResponse{
|
|
Content: `{"events":[{"title":"Archive labelled"}]}`,
|
|
Usage: promptkit.TokenUsage{
|
|
PromptTokens: 42,
|
|
CompletionTokens: 36,
|
|
TotalTokens: 78,
|
|
},
|
|
},
|
|
}
|
|
engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(fake))
|
|
|
|
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkStructuredEventsPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.File(frameworkTranscriptPath),
|
|
"glossary": promptkit.File(frameworkGlossaryPath),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run to succeed, got %v", err)
|
|
}
|
|
if result.PromptID != frameworkStructuredEventsPromptID || result.SelectedProfileID != frameworkQualityProfileID {
|
|
t.Fatalf("unexpected run metadata: %+v", result)
|
|
}
|
|
if result.RunID == "" || result.PromptHash == "" || result.RenderedPromptHash == "" {
|
|
t.Fatalf("expected run and prompt hashes, got %+v", result)
|
|
}
|
|
if result.InputHashes["transcript"] == "" || result.InputHashes["glossary"] == "" {
|
|
t.Fatalf("expected both input hashes, got %#v", result.InputHashes)
|
|
}
|
|
if len(fake.requests) != 1 || fake.requests[0].StructuredOutput == nil ||
|
|
fake.requests[0].StructuredOutput.Type != promptkit.StructuredOutputJSONSchema ||
|
|
fake.requests[0].StructuredOutput.JSONSchema == nil ||
|
|
fake.requests[0].StructuredOutput.JSONSchema.Schema == nil {
|
|
t.Fatalf("expected provider JSON Schema structured output, got %+v", fake.requests)
|
|
}
|
|
if result.Validation.Status != promptkit.ValidationPassed || !result.Validation.IsValid || result.Validation.Mode != promptkit.ValidationJSONSchema {
|
|
t.Fatalf("expected passed JSON Schema validation, got %+v", result.Validation)
|
|
}
|
|
if result.Artifact.ContentType != "application/json" {
|
|
t.Fatalf("expected JSON artifact, got %q", result.Artifact.ContentType)
|
|
}
|
|
if result.RawOutput != fake.response.Content || result.Usage != fake.response.Usage {
|
|
t.Fatalf("expected preserved output and usage, got output=%q usage=%+v", result.RawOutput, result.Usage)
|
|
}
|
|
if result.StartTime.IsZero() || result.EndTime.IsZero() || result.EndTime.Before(result.StartTime) || result.Duration < 0 {
|
|
t.Fatalf("expected ordered non-zero timestamps and non-negative duration, got start=%v end=%v duration=%v", result.StartTime, result.EndTime, result.Duration)
|
|
}
|
|
}
|
|
|
|
func TestRunPassesPreparedRequestToInjectedLLMClient(t *testing.T) {
|
|
const directKey = "direct-injected-key"
|
|
const directSession = "assembled-session"
|
|
fake := &fakeLLMClient{
|
|
response: &promptkit.GenerateResponse{Content: `{"events":[{"title":"Archive labelled"}]}`},
|
|
}
|
|
engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(fake))
|
|
|
|
runRequest := promptkit.RunRequest{
|
|
PromptID: frameworkStructuredEventsPromptID,
|
|
SessionID: " " + directSession + " ",
|
|
APIKey: directKey,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
}
|
|
prepared, err := engine.Prepare(context.Background(), runRequest)
|
|
if err != nil {
|
|
t.Fatalf("expected prepare to succeed, got %v", err)
|
|
}
|
|
result, err := engine.Run(context.Background(), runRequest)
|
|
if err != nil {
|
|
t.Fatalf("expected run to succeed, got %v", err)
|
|
}
|
|
if len(fake.requests) != 1 {
|
|
t.Fatalf("expected one generate request, got %d", len(fake.requests))
|
|
}
|
|
req := fake.requests[0]
|
|
if len(req.Prompt.Messages) != 2 || !strings.Contains(req.Prompt.Messages[1].Content, "Rin opens the gate.") {
|
|
t.Fatalf("expected rendered prompt in generate request, got %+v", req.Prompt)
|
|
}
|
|
if prepared.SessionID != directSession ||
|
|
req.Prompt.SessionID != directSession ||
|
|
result.SessionID != directSession {
|
|
t.Fatalf(
|
|
"direct session did not propagate consistently: prepared=%q generated=%q result=%q",
|
|
prepared.SessionID,
|
|
req.Prompt.SessionID,
|
|
result.SessionID,
|
|
)
|
|
}
|
|
if req.StructuredOutput == nil || req.StructuredOutput.Type != promptkit.StructuredOutputJSONSchema || req.StructuredOutput.JSONSchema == nil {
|
|
t.Fatalf("expected structured output handoff, got %+v", req.StructuredOutput)
|
|
}
|
|
if req.APIKey != directKey {
|
|
t.Fatalf("expected direct key on injected generate request")
|
|
}
|
|
payload, err := json.Marshal(req)
|
|
if err != nil {
|
|
t.Fatalf("expected generate request to marshal, got %v", err)
|
|
}
|
|
if strings.Contains(string(payload), directKey) {
|
|
t.Fatalf("generate request JSON leaked direct API key: %s", payload)
|
|
}
|
|
}
|
|
|
|
func TestEngineRunPropagatesCallerCancellation(t *testing.T) {
|
|
const synchronizationTimeout = 5 * time.Second
|
|
|
|
started := make(chan struct{})
|
|
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
close(started)
|
|
<-req.Context().Done()
|
|
return nil, req.Context().Err()
|
|
})
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: frameworkProfileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct engine: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
_, err := engine.Run(ctx, promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Nia labels the archive."),
|
|
"glossary": promptkit.Inline("archive: A catalogued collection."),
|
|
},
|
|
})
|
|
result <- err
|
|
}()
|
|
|
|
watchdog := time.NewTimer(synchronizationTimeout)
|
|
defer watchdog.Stop()
|
|
select {
|
|
case <-started:
|
|
case err := <-result:
|
|
t.Fatalf("Engine.Run returned before the transport started: %v", err)
|
|
case <-watchdog.C:
|
|
t.Fatal("timed out waiting for the transport to start")
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case err := <-result:
|
|
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
|
t.Fatalf("expected ErrLLMGenerate after caller cancellation, got %v", err)
|
|
}
|
|
case <-watchdog.C:
|
|
t.Fatal("timed out waiting for Engine.Run to return after cancellation")
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsReservedExtraParamsBeforeProviderCall(t *testing.T) {
|
|
called := false
|
|
transport := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
called = true
|
|
return nil, errors.New("provider should not be called")
|
|
})
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: frameworkProfileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
HTTPClient: &http.Client{Transport: transport},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct engine: %v", err)
|
|
}
|
|
|
|
_, err = engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Nia labels the archive."),
|
|
"glossary": promptkit.Inline("archive: A catalogued collection."),
|
|
},
|
|
Execution: &promptkit.ExecutionTargetOverride{
|
|
ExtraParams: map[string]any{"model": "collision"},
|
|
},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrInvalidRequest) {
|
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
|
}
|
|
if called {
|
|
t.Fatal("expected reserved provider parameter to fail before the provider call")
|
|
}
|
|
}
|
|
|
|
func TestRunUsesDirectAPIKeyWithDefaultLLMClient(t *testing.T) {
|
|
const directKey = "direct-public-key"
|
|
const missingEnv = "PROMPTKIT_PUBLIC_DIRECT_MISSING"
|
|
t.Setenv(missingEnv, "")
|
|
|
|
var gotAuth string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
if r.URL.Path != "/v1/chat/completions" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"choices": [{"message": {"role": "assistant", "content": "# Summary\n\nDone."}}],
|
|
"usage": {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7}
|
|
}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
profileDir := t.TempDir()
|
|
writePublicProfileFileWithAPIKeyEnv(t, profileDir, "direct-auth", server.URL+"/v1", "test-model", missingEnv)
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: profileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "direct-auth",
|
|
APIKey: directKey,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run with direct API key to succeed, got %v", err)
|
|
}
|
|
if gotAuth != "Bearer "+directKey {
|
|
t.Fatalf("unexpected Authorization header: %q", gotAuth)
|
|
}
|
|
if result.Usage.TotalTokens != 7 {
|
|
t.Fatalf("unexpected usage: %+v", result.Usage)
|
|
}
|
|
|
|
payload, err := json.Marshal(result)
|
|
if err != nil {
|
|
t.Fatalf("expected run result to marshal, got %v", err)
|
|
}
|
|
if strings.Contains(string(payload), directKey) {
|
|
t.Fatalf("run result JSON leaked direct API key: %s", payload)
|
|
}
|
|
}
|
|
|
|
func TestRunUsesResolvedBackendWithBuiltInLLMClient(t *testing.T) {
|
|
const (
|
|
backendID = "local-test"
|
|
envName = "PROMPTKIT_BACKEND_TRANSPORT_KEY"
|
|
apiKey = "synthetic-backend-key"
|
|
)
|
|
t.Setenv(envName, apiKey)
|
|
|
|
var (
|
|
gotAuth string
|
|
gotBody map[string]any
|
|
)
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
if r.URL.Path != "/v1/chat/completions" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
|
|
t.Errorf("decode request body: %v", err)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"choices": [{"message": {"role": "assistant", "content": "# Summary\n\nDone."}}],
|
|
"usage": {"prompt_tokens": 3, "completion_tokens": 4, "total_tokens": 7}
|
|
}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
},
|
|
promptkit.WithBackend(promptkit.Backend{
|
|
ID: backendID,
|
|
Endpoint: server.URL + "/v1",
|
|
APIKeyEnv: envName,
|
|
ExtraParams: map[string]any{
|
|
"provider": "synthetic",
|
|
},
|
|
}),
|
|
promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "backend-transport",
|
|
BackendID: backendID,
|
|
Model: "test-model",
|
|
}),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("construct engine: %v", err)
|
|
}
|
|
|
|
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "backend-transport",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("run with resolved backend: %v", err)
|
|
}
|
|
if gotAuth != "Bearer "+apiKey {
|
|
t.Fatalf("unexpected Authorization header: %q", gotAuth)
|
|
}
|
|
if gotBody["model"] != "test-model" || gotBody["provider"] != "synthetic" {
|
|
t.Fatalf("backend defaults did not reach provider payload: %#v", gotBody)
|
|
}
|
|
for _, field := range []string{"backend_id", "api_key_env"} {
|
|
if _, ok := gotBody[field]; ok {
|
|
t.Fatalf("internal metadata field %q was serialized to provider payload: %#v", field, gotBody)
|
|
}
|
|
}
|
|
bodyJSON, err := json.Marshal(gotBody)
|
|
if err != nil {
|
|
t.Fatalf("marshal captured provider payload: %v", err)
|
|
}
|
|
if strings.Contains(string(bodyJSON), apiKey) {
|
|
t.Fatalf("credential value was serialized to provider payload: %s", bodyJSON)
|
|
}
|
|
if result.SelectedBackendID != backendID ||
|
|
result.EffectiveModelParams.Endpoint != server.URL+"/v1" ||
|
|
result.EffectiveModelParams.APIKeyEnv != envName {
|
|
t.Fatalf("unexpected resolved backend metadata: %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestPrepareDirectAPIKeyBypassesMissingEnvWithoutLeakingOrHashing(t *testing.T) {
|
|
const missingEnv = "PROMPTKIT_PUBLIC_PREPARE_MISSING"
|
|
const firstKey = "first-direct-key"
|
|
const secondKey = "second-direct-key"
|
|
t.Setenv(missingEnv, "")
|
|
|
|
profileDir := t.TempDir()
|
|
writePublicProfileFileWithAPIKeyEnv(t, profileDir, "direct-prepare", "http://localhost:8000/v1", "test-model", missingEnv)
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: profileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
baseReq := promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "direct-prepare",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
}
|
|
firstReq := baseReq
|
|
firstReq.APIKey = firstKey
|
|
firstPrepared, err := engine.Prepare(context.Background(), firstReq)
|
|
if err != nil {
|
|
t.Fatalf("expected prepare with direct API key to succeed, got %v", err)
|
|
}
|
|
secondReq := baseReq
|
|
secondReq.APIKey = secondKey
|
|
secondPrepared, err := engine.Prepare(context.Background(), secondReq)
|
|
if err != nil {
|
|
t.Fatalf("expected prepare with alternate direct API key to succeed, got %v", err)
|
|
}
|
|
|
|
if firstPrepared.PromptHash != secondPrepared.PromptHash {
|
|
t.Fatalf("direct API keys changed prompt hash: %q vs %q", firstPrepared.PromptHash, secondPrepared.PromptHash)
|
|
}
|
|
if firstPrepared.RenderedPromptHash != secondPrepared.RenderedPromptHash {
|
|
t.Fatalf("direct API keys changed rendered prompt hash: %q vs %q", firstPrepared.RenderedPromptHash, secondPrepared.RenderedPromptHash)
|
|
}
|
|
|
|
payload, err := json.Marshal(firstPrepared)
|
|
if err != nil {
|
|
t.Fatalf("expected prepared run to marshal, got %v", err)
|
|
}
|
|
if strings.Contains(string(payload), firstKey) {
|
|
t.Fatalf("prepared run JSON leaked direct API key: %s", payload)
|
|
}
|
|
}
|
|
|
|
func TestMissingCredentialsFailClearlyWhenProfileRequiresAuth(t *testing.T) {
|
|
const missingEnv = "PROMPTKIT_PUBLIC_AUTH_MISSING"
|
|
t.Setenv(missingEnv, "")
|
|
|
|
profileDir := t.TempDir()
|
|
writePublicProfileFileWithAPIKeyEnv(t, profileDir, "requires-auth", "http://localhost:8000/v1", "test-model", missingEnv)
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: profileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "requires-auth",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrInvalidRequest) {
|
|
t.Fatalf("expected invalid request for missing credentials, got %v", err)
|
|
}
|
|
if !errors.Is(err, promptkit.ErrAPIKeyEnvMissing) {
|
|
t.Fatalf("expected missing credential environment error, got %v", err)
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), missingEnv) {
|
|
t.Fatalf("expected missing env name in error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWithArtifactReaderRejectsNilReader(t *testing.T) {
|
|
_, err := promptkit.NewEngine(contractConfig(frameworkSchemaDir), promptkit.WithArtifactReader(nil))
|
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestArtifactReaderReceivesPublicReferenceAndPreparesArtifact(t *testing.T) {
|
|
reader := &recordingArtifactReader{
|
|
artifact: &promptkit.Artifact{
|
|
ContentType: "text/plain",
|
|
Body: []byte("Reader-supplied transcript."),
|
|
URI: "reader://transcript",
|
|
Size: int64(len("Reader-supplied transcript.")),
|
|
Hash: "reader-transcript-hash",
|
|
},
|
|
}
|
|
engine := newArtifactReaderEngine(t, reader)
|
|
|
|
ref := promptkit.ArtifactRef{
|
|
Type: promptkit.ArtifactRefInline,
|
|
URI: "reader://transcript",
|
|
Body: "request body",
|
|
}
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: "artifact-reader",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": ref,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("prepare with artifact reader: %v", err)
|
|
}
|
|
if len(reader.refs) != 1 || !reflect.DeepEqual(reader.refs[0], ref) {
|
|
t.Fatalf("reader received %#v, want %#v", reader.refs, ref)
|
|
}
|
|
if prepared.InputHashes["transcript"] != "reader-transcript-hash" {
|
|
t.Fatalf("unexpected input hash: %#v", prepared.InputHashes)
|
|
}
|
|
if len(prepared.Messages) != 1 || !strings.Contains(prepared.Messages[0].Content, "Reader-supplied transcript.") {
|
|
t.Fatalf("prepared prompt omitted reader artifact: %#v", prepared.Messages)
|
|
}
|
|
}
|
|
|
|
func TestArtifactReaderFailuresPreserveArtifactLoadErrors(t *testing.T) {
|
|
readerErr := errors.New("artifact reader failed")
|
|
|
|
tests := []struct {
|
|
name string
|
|
ctx context.Context
|
|
reader *recordingArtifactReader
|
|
wantNested error
|
|
}{
|
|
{
|
|
name: "reader error",
|
|
ctx: context.Background(),
|
|
reader: &recordingArtifactReader{err: readerErr},
|
|
wantNested: readerErr,
|
|
},
|
|
{
|
|
name: "nil artifact",
|
|
ctx: context.Background(),
|
|
reader: &recordingArtifactReader{},
|
|
},
|
|
{
|
|
name: "reader cancellation",
|
|
ctx: context.Background(),
|
|
reader: &recordingArtifactReader{read: func(context.Context, promptkit.ArtifactRef) (*promptkit.Artifact, error) {
|
|
return nil, context.Canceled
|
|
}},
|
|
wantNested: context.Canceled,
|
|
},
|
|
{
|
|
name: "public reader error",
|
|
ctx: context.Background(),
|
|
reader: &recordingArtifactReader{err: promptkit.ErrInvalidRequest},
|
|
wantNested: promptkit.ErrInvalidRequest,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
engine := newArtifactReaderEngine(t, tc.reader)
|
|
_, err := engine.Prepare(tc.ctx, promptkit.RunRequest{
|
|
PromptID: "artifact-reader",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("input"),
|
|
},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrArtifactLoad) {
|
|
t.Fatalf("expected ErrArtifactLoad, got %v", err)
|
|
}
|
|
if tc.wantNested != nil && !errors.Is(err, tc.wantNested) {
|
|
t.Fatalf("expected nested %v, got %v", tc.wantNested, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunAddsLLMGenerateToCollaboratorPublicError(t *testing.T) {
|
|
engine := newContractEngineWithOptions(t, frameworkSchemaDir,
|
|
promptkit.WithLLMClient(&fakeLLMClient{err: promptkit.ErrArtifactLoad}),
|
|
)
|
|
|
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrLLMGenerate) {
|
|
t.Fatalf("expected ErrLLMGenerate, got %v", err)
|
|
}
|
|
if !errors.Is(err, promptkit.ErrArtifactLoad) {
|
|
t.Fatalf("expected preserved ErrArtifactLoad, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepareWithoutProfileMatchesSpecificPublicError(t *testing.T) {
|
|
promptDir := t.TempDir()
|
|
writePublicPromptFile(t, promptDir, "profile-required", "")
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: promptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct engine: %v", err)
|
|
}
|
|
|
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: "profile-required",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("input"),
|
|
},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrInvalidRequest) {
|
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
|
}
|
|
if !errors.Is(err, promptkit.ErrProfileRequired) {
|
|
t.Fatalf("expected ErrProfileRequired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunValidationFailureReturnsResult(t *testing.T) {
|
|
fake := &fakeLLMClient{
|
|
response: &promptkit.GenerateResponse{Content: ""},
|
|
}
|
|
engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(fake))
|
|
|
|
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected validation failure as successful result, got %v", err)
|
|
}
|
|
if result.Validation.Status != promptkit.ValidationFailed || result.Validation.IsValid {
|
|
t.Fatalf("expected failed validation result, got %+v", result.Validation)
|
|
}
|
|
if len(result.Validation.Errors) == 0 {
|
|
t.Fatalf("expected validation errors")
|
|
}
|
|
}
|
|
|
|
func TestPublicErrorsSupportErrorsIs(t *testing.T) {
|
|
llmErr := errors.New("llm failed")
|
|
|
|
tests := []struct {
|
|
name string
|
|
req promptkit.RunRequest
|
|
client promptkit.LLMClient
|
|
schemaDir string
|
|
want error
|
|
notWant error
|
|
}{
|
|
{
|
|
name: "invalid request",
|
|
req: promptkit.RunRequest{},
|
|
client: &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}},
|
|
want: promptkit.ErrInvalidRequest,
|
|
},
|
|
{
|
|
name: "prompt not found",
|
|
req: promptkit.RunRequest{PromptID: "missing.prompt"},
|
|
client: &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}},
|
|
want: promptkit.ErrPromptNotFound,
|
|
notWant: promptkit.ErrPromptLoad,
|
|
},
|
|
{
|
|
name: "profile not found",
|
|
req: promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "missing-profile",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
},
|
|
},
|
|
client: &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}},
|
|
want: promptkit.ErrProfileNotFound,
|
|
notWant: promptkit.ErrProfileLoad,
|
|
},
|
|
{
|
|
name: "artifact load",
|
|
req: promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.File(filepath.Join(t.TempDir(), "does-not-exist.md")),
|
|
},
|
|
},
|
|
client: &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}},
|
|
want: promptkit.ErrArtifactLoad,
|
|
},
|
|
{
|
|
name: "prompt render",
|
|
req: promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
},
|
|
client: &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}},
|
|
want: promptkit.ErrPromptRender,
|
|
},
|
|
{
|
|
name: "llm failure",
|
|
req: promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
},
|
|
client: &fakeLLMClient{err: llmErr},
|
|
want: promptkit.ErrLLMGenerate,
|
|
},
|
|
{
|
|
name: "nil llm response",
|
|
req: promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
},
|
|
client: &fakeLLMClient{},
|
|
want: promptkit.ErrLLMGenerate,
|
|
},
|
|
{
|
|
name: "validation runtime failure",
|
|
req: promptkit.RunRequest{
|
|
PromptID: frameworkStructuredEventsPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
},
|
|
},
|
|
client: &fakeLLMClient{response: &promptkit.GenerateResponse{Content: `{"events":[]}`}},
|
|
schemaDir: t.TempDir(),
|
|
want: promptkit.ErrValidation,
|
|
},
|
|
}
|
|
|
|
t.Setenv("PROMPTKIT_API_KEY", "test-secret")
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
schemaDir := tc.schemaDir
|
|
if schemaDir == "" {
|
|
schemaDir = frameworkSchemaDir
|
|
}
|
|
engine := newContractEngineWithOptions(t, schemaDir, promptkit.WithLLMClient(tc.client))
|
|
_, err := engine.Run(context.Background(), tc.req)
|
|
if !errors.Is(err, tc.want) {
|
|
t.Fatalf("expected errors.Is(%v), got %v", tc.want, err)
|
|
}
|
|
if tc.notWant != nil && errors.Is(err, tc.notWant) {
|
|
t.Fatalf("did not expect errors.Is(%v), got %v", tc.notWant, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSelectedProfileRawAPIKeyMapsToProfileLoad(t *testing.T) {
|
|
profileDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(profileDir, "raw.yaml"), []byte(`
|
|
id: raw-profile
|
|
endpoint: http://localhost:8000/v1
|
|
model: model
|
|
api_key: secret
|
|
`), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: profileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "raw-profile",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrProfileLoad) {
|
|
t.Fatalf("expected ErrProfileLoad, got %v", err)
|
|
}
|
|
if errors.Is(err, promptkit.ErrPromptLoad) {
|
|
t.Fatalf("did not expect ErrPromptLoad, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSelectedProfileInvalidYAMLMapsToProfileLoad(t *testing.T) {
|
|
profileDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(profileDir, "broken.yaml"), []byte(`
|
|
id: broken-profile
|
|
unknown_field: true
|
|
`), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: profileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "broken-profile",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrProfileLoad) {
|
|
t.Fatalf("expected ErrProfileLoad, got %v", err)
|
|
}
|
|
if errors.Is(err, promptkit.ErrPromptLoad) {
|
|
t.Fatalf("did not expect ErrPromptLoad, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPromptRepositoryReadFailureMapsToPromptLoad(t *testing.T) {
|
|
missingPromptDir := filepath.Join(t.TempDir(), "missing-prompts")
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: missingPromptDir,
|
|
ProfileDir: frameworkProfileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrPromptLoad) {
|
|
t.Fatalf("expected ErrPromptLoad, got %v", err)
|
|
}
|
|
if errors.Is(err, promptkit.ErrProfileLoad) {
|
|
t.Fatalf("did not expect ErrProfileLoad, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSelectedProfileRepositoryReadFailureMapsToProfileLoad(t *testing.T) {
|
|
missingProfileDir := filepath.Join(t.TempDir(), "missing-profiles")
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: missingProfileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: frameworkFastProfileID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrProfileLoad) {
|
|
t.Fatalf("expected ErrProfileLoad, got %v", err)
|
|
}
|
|
if errors.Is(err, promptkit.ErrPromptLoad) {
|
|
t.Fatalf("did not expect ErrPromptLoad, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepareUsesBuiltInProfileWithoutProfileDir(t *testing.T) {
|
|
t.Setenv("OPENROUTER_API_KEY", "test-key")
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "mistral-small-3",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected built-in profile prepare to succeed, got %v", err)
|
|
}
|
|
if prepared.SelectedProfileID != "mistral-small-3" {
|
|
t.Fatalf("unexpected selected profile: %q", prepared.SelectedProfileID)
|
|
}
|
|
if prepared.SelectedBackendID != promptkit.BackendOpenRouter {
|
|
t.Fatalf("unexpected selected backend: %q", prepared.SelectedBackendID)
|
|
}
|
|
if prepared.EffectiveModelParams.BackendID != promptkit.BackendOpenRouter {
|
|
t.Fatalf("unexpected effective backend: %q", prepared.EffectiveModelParams.BackendID)
|
|
}
|
|
if prepared.EffectiveModelParams.Endpoint != "https://openrouter.ai/api/v1" {
|
|
t.Fatalf("unexpected built-in endpoint: %q", prepared.EffectiveModelParams.Endpoint)
|
|
}
|
|
if prepared.EffectiveModelParams.APIKeyEnv != "OPENROUTER_API_KEY" {
|
|
t.Fatalf("unexpected built-in api key environment name: %q", prepared.EffectiveModelParams.APIKeyEnv)
|
|
}
|
|
if prepared.EffectiveModelParams.Model != "mistralai/mistral-small-3.2-24b-instruct" {
|
|
t.Fatalf("unexpected built-in model: %q", prepared.EffectiveModelParams.Model)
|
|
}
|
|
}
|
|
|
|
func TestPromptDefaultProfileCanUseBuiltInProfile(t *testing.T) {
|
|
t.Setenv("OPENROUTER_API_KEY", "test-key")
|
|
promptDir := t.TempDir()
|
|
writePublicPromptFile(t, promptDir, "prompt.builtin.default", "mistral-small-3")
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{PromptDir: promptDir})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: "prompt.builtin.default",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected built-in default profile prepare to succeed, got %v", err)
|
|
}
|
|
if prepared.SelectedProfileID != "mistral-small-3" {
|
|
t.Fatalf("unexpected selected profile: %q", prepared.SelectedProfileID)
|
|
}
|
|
}
|
|
|
|
func TestCustomProfileOverridesBuiltInProfile(t *testing.T) {
|
|
t.Setenv("OPENROUTER_API_KEY", "test-key")
|
|
profileDir := t.TempDir()
|
|
writePublicProfileFile(t, profileDir, "mistral-small-3", "http://localhost:8000/v1", "custom-model")
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: profileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "mistral-small-3",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected custom profile prepare to succeed, got %v", err)
|
|
}
|
|
if prepared.EffectiveModelParams.Model != "custom-model" {
|
|
t.Fatalf("expected custom profile to override built-in, got %q", prepared.EffectiveModelParams.Model)
|
|
}
|
|
}
|
|
|
|
func TestMalformedCustomProfileDoesNotFallbackToBuiltIn(t *testing.T) {
|
|
t.Setenv("OPENROUTER_API_KEY", "test-key")
|
|
profileDir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(profileDir, "mistral-small-3.yml"), []byte(`
|
|
id: mistral-small-3
|
|
endpoint: http://localhost:8000/v1
|
|
model: custom-model
|
|
unexpected: true
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: profileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "mistral-small-3",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrProfileLoad) {
|
|
t.Fatalf("expected custom profile load error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepareWorksWithPromptFSAndRelativeContentFile(t *testing.T) {
|
|
promptFS := fstest.MapFS{
|
|
"assets/prompts/fs-summary.yaml": &fstest.MapFile{Data: []byte(`
|
|
id: fs.summary
|
|
version: "1.0.0"
|
|
default_profile: contract-fast
|
|
inputs:
|
|
- name: transcript
|
|
required: true
|
|
messages:
|
|
- role: user
|
|
content_file: ./messages/summary.tmpl
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
repair_attempts: 0
|
|
`)},
|
|
"assets/prompts/messages/summary.tmpl": &fstest.MapFile{Data: []byte(`Summarize {{input "transcript"}} from prompt fs.`)},
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: t.TempDir(),
|
|
ProfileDir: frameworkProfileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithPromptFS(promptFS, "assets/prompts"))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: "fs.summary",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected prepare to succeed, got %v", err)
|
|
}
|
|
if len(prepared.Messages) != 1 || !strings.Contains(prepared.Messages[0].Content, "prompt fs") {
|
|
t.Fatalf("expected content_file body from prompt fs, got %+v", prepared.Messages)
|
|
}
|
|
}
|
|
|
|
func TestPrepareWithPromptFSRejectsEscapedContentFile(t *testing.T) {
|
|
promptFS := fstest.MapFS{
|
|
"assets/prompts/fs-escape.yaml": &fstest.MapFile{Data: []byte(`
|
|
id: fs.escape
|
|
version: "1.0.0"
|
|
default_profile: contract-fast
|
|
messages:
|
|
- role: user
|
|
content_file: ../outside.tmpl
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
repair_attempts: 0
|
|
`)},
|
|
"assets/outside.tmpl": &fstest.MapFile{Data: []byte(`Outside root.`)},
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: t.TempDir(),
|
|
ProfileDir: frameworkProfileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithPromptFS(promptFS, "assets/prompts"))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: "fs.escape"})
|
|
if !errors.Is(err, promptkit.ErrPromptLoad) {
|
|
t.Fatalf("expected ErrPromptLoad, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPrepareWorksWithPromptFile(t *testing.T) {
|
|
promptDir := t.TempDir()
|
|
promptPath := filepath.Join(promptDir, "single.yaml")
|
|
if err := os.WriteFile(promptPath, []byte(`
|
|
id: single.file.prompt
|
|
version: "1.0.0"
|
|
default_profile: contract-fast
|
|
inputs:
|
|
- name: transcript
|
|
required: true
|
|
messages:
|
|
- role: user
|
|
content_file: ./single.tmpl
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
repair_attempts: 0
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(promptDir, "single.tmpl"), []byte(`Summarize {{input "transcript"}} from file.`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
ProfileDir: frameworkProfileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithPromptFile(promptPath))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: "single.file.prompt",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected prepare to succeed, got %v", err)
|
|
}
|
|
if prepared.PromptID != "single.file.prompt" {
|
|
t.Fatalf("unexpected prompt id: %q", prepared.PromptID)
|
|
}
|
|
if len(prepared.Messages) != 1 || !strings.Contains(prepared.Messages[0].Content, "from file") {
|
|
t.Fatalf("expected content_file body from prompt file, got %+v", prepared.Messages)
|
|
}
|
|
}
|
|
|
|
func TestPrepareWorksWithProfileFSOverBuiltIns(t *testing.T) {
|
|
profileFS := fstest.MapFS{
|
|
"profiles/mistral-small-3.yaml": &fstest.MapFile{Data: []byte(`
|
|
id: mistral-small-3
|
|
endpoint: http://profile-fs/v1
|
|
model: profile-fs-model
|
|
`)},
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithProfileFS(profileFS, "profiles"))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "mistral-small-3",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected prepare to succeed, got %v", err)
|
|
}
|
|
if prepared.EffectiveModelParams.Model != "profile-fs-model" {
|
|
t.Fatalf("expected profile fs to override built-in, got %q", prepared.EffectiveModelParams.Model)
|
|
}
|
|
}
|
|
|
|
func TestPrepareWorksWithProfileFileOverBuiltIns(t *testing.T) {
|
|
profileDir := t.TempDir()
|
|
profilePath := filepath.Join(profileDir, "mistral-small-3.yaml")
|
|
if err := os.WriteFile(profilePath, []byte(`
|
|
id: mistral-small-3
|
|
endpoint: http://profile-file/v1
|
|
model: profile-file-model
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithProfileFile(profilePath))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "mistral-small-3",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected prepare to succeed, got %v", err)
|
|
}
|
|
if prepared.EffectiveModelParams.Model != "profile-file-model" {
|
|
t.Fatalf("expected profile file to override built-in, got %q", prepared.EffectiveModelParams.Model)
|
|
}
|
|
}
|
|
|
|
func TestPrepareWorksWithInMemoryProfilesWithoutProfileFiles(t *testing.T) {
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "memory-profile",
|
|
Endpoint: "http://memory-profile/v1",
|
|
Model: "memory-model",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "memory-profile",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected prepare to succeed, got %v", err)
|
|
}
|
|
if prepared.EffectiveModelParams.Model != "memory-model" {
|
|
t.Fatalf("expected in-memory profile model, got %q", prepared.EffectiveModelParams.Model)
|
|
}
|
|
}
|
|
|
|
func TestInMemoryProfilesOverrideBuiltInsAndProfileSources(t *testing.T) {
|
|
profileFS := fstest.MapFS{
|
|
"profiles/mistral-small-3.yaml": &fstest.MapFile{Data: []byte(`
|
|
id: mistral-small-3
|
|
endpoint: http://profile-fs/v1
|
|
model: profile-fs-model
|
|
`)},
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
},
|
|
promptkit.WithProfileFS(profileFS, "profiles"),
|
|
promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "mistral-small-3",
|
|
Endpoint: "http://memory-profile/v1",
|
|
Model: "memory-profile-model",
|
|
}),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "mistral-small-3",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected prepare to succeed, got %v", err)
|
|
}
|
|
if prepared.EffectiveModelParams.Model != "memory-profile-model" {
|
|
t.Fatalf("expected in-memory profile to have highest precedence, got %q", prepared.EffectiveModelParams.Model)
|
|
}
|
|
}
|
|
|
|
func TestWithProfilesRejectsDuplicateIDs(t *testing.T) {
|
|
_, err := promptkit.NewEngine(promptkit.Config{PromptDir: frameworkPromptDir},
|
|
promptkit.WithProfiles(
|
|
promptkit.Profile{ID: "duplicate", Endpoint: "http://one/v1", Model: "one"},
|
|
promptkit.Profile{ID: "duplicate", Endpoint: "http://two/v1", Model: "two"},
|
|
),
|
|
)
|
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleProfileRunsThroughNormalProfilePath(t *testing.T) {
|
|
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
|
prof := promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
|
|
ID: "template-profile",
|
|
BackendID: " openrouter ",
|
|
Endpoint: "http://template/v1",
|
|
Model: "template-model",
|
|
APIKeyRequired: true,
|
|
ExtraParams: map[string]any{
|
|
"provider": "template",
|
|
},
|
|
})
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithProfiles(prof), promptkit.WithLLMClient(fake))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
_, err = engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "template-profile",
|
|
APIKey: "template-key",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run to succeed, got %v", err)
|
|
}
|
|
if len(fake.requests) != 1 {
|
|
t.Fatalf("expected one request, got %d", len(fake.requests))
|
|
}
|
|
if fake.requests[0].Target.BackendID != promptkit.BackendOpenRouter ||
|
|
fake.requests[0].Target.Model != "template-model" ||
|
|
fake.requests[0].APIKey != "template-key" {
|
|
t.Fatalf("unexpected generated request: %+v", fake.requests[0])
|
|
}
|
|
if !reflect.DeepEqual(fake.requests[0].Target.ExtraParams, map[string]any{"provider": "template"}) {
|
|
t.Fatalf("unexpected extra params: %#v", fake.requests[0].Target.ExtraParams)
|
|
}
|
|
}
|
|
|
|
func TestEngineRunLayersTransportAndGenerationTimeouts(t *testing.T) {
|
|
intPointer := func(value int) *int {
|
|
return &value
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
configTimeout time.Duration
|
|
suppliedClientTimeout time.Duration
|
|
profileTimeoutSeconds int
|
|
requestTimeoutSeconds *int
|
|
callerTimeout time.Duration
|
|
wantRemainingAtRequest time.Duration
|
|
}{
|
|
{
|
|
name: "positive supplied client cap takes precedence over config",
|
|
configTimeout: 2 * time.Second,
|
|
suppliedClientTimeout: 6 * time.Second,
|
|
wantRemainingAtRequest: 6 * time.Second,
|
|
},
|
|
{
|
|
name: "zero supplied client timeout inherits config cap",
|
|
configTimeout: 5 * time.Second,
|
|
wantRemainingAtRequest: 5 * time.Second,
|
|
},
|
|
{
|
|
name: "zero configuration uses ten minute transport default",
|
|
wantRemainingAtRequest: 10 * time.Minute,
|
|
},
|
|
{
|
|
name: "negative configuration uses ten minute transport default",
|
|
configTimeout: -2 * time.Second,
|
|
suppliedClientTimeout: -3 * time.Second,
|
|
wantRemainingAtRequest: 10 * time.Minute,
|
|
},
|
|
{
|
|
name: "profile deadline is shorter than transport cap",
|
|
suppliedClientTimeout: 6 * time.Second,
|
|
profileTimeoutSeconds: 4,
|
|
wantRemainingAtRequest: 4 * time.Second,
|
|
},
|
|
{
|
|
name: "request deadline is shorter than profile and transport limits",
|
|
suppliedClientTimeout: 6 * time.Second,
|
|
profileTimeoutSeconds: 4,
|
|
requestTimeoutSeconds: intPointer(2),
|
|
wantRemainingAtRequest: 2 * time.Second,
|
|
},
|
|
{
|
|
name: "explicit zero removes generation deadline but retains transport cap",
|
|
suppliedClientTimeout: 5 * time.Second,
|
|
profileTimeoutSeconds: 2,
|
|
requestTimeoutSeconds: intPointer(0),
|
|
wantRemainingAtRequest: 5 * time.Second,
|
|
},
|
|
{
|
|
name: "framework default remains layered with shorter transport cap",
|
|
configTimeout: 7 * time.Second,
|
|
suppliedClientTimeout: 3 * time.Second,
|
|
wantRemainingAtRequest: 3 * time.Second,
|
|
},
|
|
{
|
|
name: "caller deadline remains layered with other limits",
|
|
suppliedClientTimeout: 6 * time.Second,
|
|
profileTimeoutSeconds: 4,
|
|
callerTimeout: 2 * time.Second,
|
|
wantRemainingAtRequest: 2 * time.Second,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var (
|
|
sawDeadline bool
|
|
remaining time.Duration
|
|
)
|
|
transport := roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
deadline, ok := req.Context().Deadline()
|
|
sawDeadline = ok
|
|
if ok {
|
|
remaining = time.Until(deadline)
|
|
}
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Status: "200 OK",
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"choices":[{"message":{"content":"ok"}}]}`,
|
|
)),
|
|
Request: req,
|
|
}, nil
|
|
})
|
|
httpClient := &http.Client{
|
|
Timeout: tc.suppliedClientTimeout,
|
|
Transport: transport,
|
|
}
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
Timeout: tc.configTimeout,
|
|
HTTPClient: httpClient,
|
|
}, promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "layered-timeout",
|
|
Endpoint: "http://timeout.test/v1",
|
|
Model: "timeout-model",
|
|
TimeoutSeconds: tc.profileTimeoutSeconds,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
cancel := func() {}
|
|
if tc.callerTimeout > 0 {
|
|
ctx, cancel = context.WithTimeout(ctx, tc.callerTimeout)
|
|
}
|
|
defer cancel()
|
|
|
|
_, err = engine.Run(ctx, promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "layered-timeout",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
Execution: &promptkit.ExecutionTargetOverride{
|
|
TimeoutSeconds: tc.requestTimeoutSeconds,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run to succeed, got %v", err)
|
|
}
|
|
if !sawDeadline {
|
|
t.Fatal("expected outbound request context to have a deadline")
|
|
}
|
|
|
|
const deadlineTolerance = 750 * time.Millisecond
|
|
if remaining < tc.wantRemainingAtRequest-deadlineTolerance ||
|
|
remaining > tc.wantRemainingAtRequest+50*time.Millisecond {
|
|
t.Fatalf(
|
|
"unexpected request deadline: remaining=%v want approximately %v",
|
|
remaining,
|
|
tc.wantRemainingAtRequest,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleProfileDefersExtraParamsValidation(t *testing.T) {
|
|
cyclic := map[string]any{}
|
|
cyclic["self"] = cyclic
|
|
|
|
prof := promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
|
|
ID: "cyclic-template-profile",
|
|
Endpoint: "http://cyclic-template/v1",
|
|
Model: "cyclic-template-model",
|
|
ExtraParams: cyclic,
|
|
})
|
|
|
|
_, err := promptkit.NewEngine(promptkit.Config{PromptDir: frameworkPromptDir},
|
|
promptkit.WithProfiles(prof),
|
|
)
|
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleProfileNestedExtraParamsRunThroughWithProfiles(t *testing.T) {
|
|
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
|
nested := map[string]any{
|
|
"labels": map[string]string{"route": "primary"},
|
|
"ids": []int{1, 2, 3},
|
|
}
|
|
extraParams := map[string]any{
|
|
"nested": nested,
|
|
}
|
|
prof := promptkit.OpenAICompatibleProfile(promptkit.OpenAICompatibleProfileConfig{
|
|
ID: "nested-template-profile",
|
|
Endpoint: "http://nested-template/v1",
|
|
Model: "nested-template-model",
|
|
ExtraParams: extraParams,
|
|
})
|
|
extraParams["added"] = "mutated-after-construction"
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithProfiles(prof), promptkit.WithLLMClient(fake))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
nested["added"] = "mutated-after-construction"
|
|
|
|
_, err = engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "nested-template-profile",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run to succeed, got %v", err)
|
|
}
|
|
want := map[string]any{
|
|
"nested": map[string]any{
|
|
"labels": map[string]string{"route": "primary"},
|
|
"ids": []int{1, 2, 3},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(fake.requests[0].Target.ExtraParams, want) {
|
|
t.Fatalf("unexpected extra params:\ngot=%#v\nwant=%#v", fake.requests[0].Target.ExtraParams, want)
|
|
}
|
|
}
|
|
|
|
func TestInMemoryProfileAPIKeyRequiredBehavior(t *testing.T) {
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "requires-key",
|
|
Endpoint: "http://requires-key/v1",
|
|
Model: "requires-key-model",
|
|
APIKeyRequired: true,
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
req := promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "requires-key",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
}
|
|
_, err = engine.Prepare(context.Background(), req)
|
|
if !errors.Is(err, promptkit.ErrInvalidRequest) {
|
|
t.Fatalf("expected ErrInvalidRequest without API key, got %v", err)
|
|
}
|
|
req.APIKey = "direct-required-key"
|
|
if _, err := engine.Prepare(context.Background(), req); err != nil {
|
|
t.Fatalf("expected direct API key to satisfy APIKeyRequired, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInMemoryProfileWithoutAPIKeyRequiredWorksWithoutKey(t *testing.T) {
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "no-key-required",
|
|
Endpoint: "http://no-key/v1",
|
|
Model: "no-key-model",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
_, err = engine.Prepare(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "no-key-required",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected prepare without API key to succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInMemoryProfileExtraParamsAreCopiedAcrossPublicBoundary(t *testing.T) {
|
|
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
|
labels := map[string]string{"route": "primary"}
|
|
ids := []int{1, 2, 3}
|
|
extraParams := map[string]any{
|
|
"labels": labels,
|
|
"ids": ids,
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
},
|
|
promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "copy-profile",
|
|
Endpoint: "http://copy/v1",
|
|
Model: "copy-model",
|
|
ExtraParams: extraParams,
|
|
}),
|
|
promptkit.WithLLMClient(fake),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
labels["route"] = "mutated-before-run"
|
|
ids[0] = 99
|
|
extraParams["added"] = "mutated"
|
|
|
|
_, err = engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "copy-profile",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run to succeed, got %v", err)
|
|
}
|
|
want := map[string]any{
|
|
"labels": map[string]string{"route": "primary"},
|
|
"ids": []int{1, 2, 3},
|
|
}
|
|
if !reflect.DeepEqual(fake.requests[0].Target.ExtraParams, want) {
|
|
t.Fatalf("captured extra params changed after mutation:\ngot=%#v\nwant=%#v", fake.requests[0].Target.ExtraParams, want)
|
|
}
|
|
}
|
|
|
|
func TestWithProfilesRejectsInvalidExtraParams(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
extraParams map[string]any
|
|
}{
|
|
{name: "function", extraParams: map[string]any{"bad": func() {}}},
|
|
{name: "channel", extraParams: map[string]any{"bad": make(chan struct{})}},
|
|
{name: "struct", extraParams: map[string]any{"bad": struct{ Name string }{Name: "bad"}}},
|
|
{name: "non string map key", extraParams: map[string]any{"bad": map[int]string{1: "one"}}},
|
|
{name: "nan", extraParams: map[string]any{"bad": math.NaN()}},
|
|
{name: "positive infinity", extraParams: map[string]any{"bad": math.Inf(1)}},
|
|
{name: "negative infinity", extraParams: map[string]any{"bad": math.Inf(-1)}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := promptkit.NewEngine(promptkit.Config{PromptDir: frameworkPromptDir},
|
|
promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "invalid-extra-params",
|
|
Endpoint: "http://invalid/v1",
|
|
Model: "invalid-model",
|
|
ExtraParams: tc.extraParams,
|
|
}),
|
|
)
|
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWithProfilesRejectsCyclicExtraParams(t *testing.T) {
|
|
cyclicMap := map[string]any{}
|
|
cyclicMap["self"] = cyclicMap
|
|
cyclicSlice := []any{nil}
|
|
cyclicSlice[0] = cyclicSlice
|
|
|
|
tests := []struct {
|
|
name string
|
|
extraParams map[string]any
|
|
}{
|
|
{name: "map", extraParams: cyclicMap},
|
|
{name: "slice", extraParams: map[string]any{"cycle": cyclicSlice}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := promptkit.NewEngine(promptkit.Config{PromptDir: frameworkPromptDir},
|
|
promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "cyclic-extra-params",
|
|
Endpoint: "http://cyclic/v1",
|
|
Model: "cyclic-model",
|
|
ExtraParams: tc.extraParams,
|
|
}),
|
|
)
|
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunStructuredOutputWorksWithSchemaFS(t *testing.T) {
|
|
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: `{"events":[]}`}}
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: t.TempDir(),
|
|
ProfileDir: frameworkProfileDir,
|
|
SchemaDir: t.TempDir(),
|
|
},
|
|
promptkit.WithPromptFS(publicStructuredPromptFS("schema.fs.prompt", "events.schema.json"), "prompts"),
|
|
promptkit.WithSchemaFS(publicSchemaFS(), "schemas"),
|
|
promptkit.WithLLMClient(fake),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: "schema.fs.prompt",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run to succeed, got %v", err)
|
|
}
|
|
if result.Validation.Status != promptkit.ValidationPassed || !result.Validation.IsValid {
|
|
t.Fatalf("expected schema validation to pass, got %+v", result.Validation)
|
|
}
|
|
if len(fake.requests) != 1 || fake.requests[0].StructuredOutput == nil {
|
|
t.Fatalf("expected structured output request, got %+v", fake.requests)
|
|
}
|
|
}
|
|
|
|
func TestRunStructuredOutputWorksWithSchemaFile(t *testing.T) {
|
|
schemaDir := t.TempDir()
|
|
schemaPath := filepath.Join(schemaDir, "events.schema.json")
|
|
if err := os.WriteFile(schemaPath, []byte(publicSchemaJSON()), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: `{"events":[]}`}}
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
ProfileDir: frameworkProfileDir,
|
|
},
|
|
promptkit.WithPromptFS(publicStructuredPromptFS("schema.file.prompt", "events.schema.json"), "prompts"),
|
|
promptkit.WithSchemaFile(schemaPath),
|
|
promptkit.WithLLMClient(fake),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
|
|
result, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: "schema.file.prompt",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run to succeed, got %v", err)
|
|
}
|
|
if result.Validation.Status != promptkit.ValidationPassed || !result.Validation.IsValid {
|
|
t.Fatalf("expected schema validation to pass, got %+v", result.Validation)
|
|
}
|
|
}
|
|
|
|
func TestSourceOptionsRejectInvalidInputs(t *testing.T) {
|
|
missingFile := filepath.Join(t.TempDir(), "missing.yaml")
|
|
directoryPath := t.TempDir()
|
|
|
|
tests := []struct {
|
|
name string
|
|
opt promptkit.Option
|
|
}{
|
|
{name: "prompt fs nil", opt: promptkit.WithPromptFS(nil, "prompts")},
|
|
{name: "prompt fs empty root", opt: promptkit.WithPromptFS(fstest.MapFS{}, "")},
|
|
{name: "prompt file empty", opt: promptkit.WithPromptFile("")},
|
|
{name: "prompt file missing", opt: promptkit.WithPromptFile(missingFile)},
|
|
{name: "prompt file directory", opt: promptkit.WithPromptFile(directoryPath)},
|
|
{name: "profile fs nil", opt: promptkit.WithProfileFS(nil, "profiles")},
|
|
{name: "profile fs empty root", opt: promptkit.WithProfileFS(fstest.MapFS{}, "")},
|
|
{name: "profile file empty", opt: promptkit.WithProfileFile("")},
|
|
{name: "fallback profile fs nil", opt: promptkit.WithFallbackProfileFS(nil, "profiles")},
|
|
{name: "fallback profile fs empty root", opt: promptkit.WithFallbackProfileFS(fstest.MapFS{}, "")},
|
|
{name: "schema fs nil", opt: promptkit.WithSchemaFS(nil, "schemas")},
|
|
{name: "schema fs empty root", opt: promptkit.WithSchemaFS(fstest.MapFS{}, "")},
|
|
{name: "schema file empty", opt: promptkit.WithSchemaFile("")},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := promptkit.NewEngine(promptkit.Config{PromptDir: frameworkPromptDir}, tc.opt)
|
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPackageOptionsComposeFromSlice(t *testing.T) {
|
|
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
|
options := []promptkit.Option{
|
|
nil,
|
|
promptkit.WithProfiles(promptkit.Profile{
|
|
ID: "slice-profile",
|
|
Endpoint: "http://slice/v1",
|
|
Model: "slice-model",
|
|
}),
|
|
promptkit.WithLLMClient(fake),
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, options...)
|
|
if err != nil {
|
|
t.Fatalf("expected package-provided options to compose, got %v", err)
|
|
}
|
|
|
|
_, err = engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
ProfileID: "slice-profile",
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run with composed options to succeed, got %v", err)
|
|
}
|
|
if len(fake.requests) != 1 {
|
|
t.Fatalf("expected one generate request, got %d", len(fake.requests))
|
|
}
|
|
if fake.requests[0].Target.Model != "slice-model" {
|
|
t.Fatalf("expected profile from composed options, got %q", fake.requests[0].Target.Model)
|
|
}
|
|
}
|
|
|
|
func TestExtraParamsTypedNestedValuesAreCopiedAcrossPublicBoundary(t *testing.T) {
|
|
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
|
engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(fake))
|
|
|
|
labels := map[string]string{"route": "primary"}
|
|
counts := map[string]int{"retry_budget": 2}
|
|
weights := []float64{0.25, 0.75}
|
|
ids := []int{1, 2, 3}
|
|
nested := map[string]any{
|
|
"labels": labels,
|
|
"counts": counts,
|
|
"weights": weights,
|
|
"ids": ids,
|
|
}
|
|
extraParams := map[string]any{
|
|
"labels": labels,
|
|
"counts": counts,
|
|
"nested": nested,
|
|
}
|
|
|
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
Execution: &promptkit.ExecutionTargetOverride{ExtraParams: extraParams},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected run to succeed, got %v", err)
|
|
}
|
|
if len(fake.requests) != 1 {
|
|
t.Fatalf("expected one generate request, got %d", len(fake.requests))
|
|
}
|
|
|
|
captured := fake.requests[0].Target.ExtraParams
|
|
labels["route"] = "mutated"
|
|
counts["retry_budget"] = 99
|
|
weights[0] = 9.9
|
|
ids[0] = 99
|
|
nested["added"] = "mutated"
|
|
extraParams["new_top_level"] = "mutated"
|
|
|
|
want := map[string]any{
|
|
"labels": map[string]string{"route": "primary"},
|
|
"counts": map[string]int{"retry_budget": 2},
|
|
"nested": map[string]any{
|
|
"labels": map[string]string{"route": "primary"},
|
|
"counts": map[string]int{"retry_budget": 2},
|
|
"weights": []float64{0.25, 0.75},
|
|
"ids": []int{1, 2, 3},
|
|
},
|
|
}
|
|
if !reflect.DeepEqual(captured, want) {
|
|
t.Fatalf("captured extra_params changed after mutating source:\ngot=%#v\nwant=%#v", captured, want)
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsInvalidExtraParams(t *testing.T) {
|
|
cyclicMap := map[string]any{}
|
|
cyclicMap["self"] = cyclicMap
|
|
cyclicSlice := []any{nil}
|
|
cyclicSlice[0] = cyclicSlice
|
|
|
|
tests := []struct {
|
|
name string
|
|
extraParams map[string]any
|
|
}{
|
|
{name: "function", extraParams: map[string]any{"bad": func() {}}},
|
|
{name: "channel", extraParams: map[string]any{"bad": make(chan struct{})}},
|
|
{name: "struct", extraParams: map[string]any{"bad": struct{ Name string }{Name: "bad"}}},
|
|
{name: "non string map key", extraParams: map[string]any{"bad": map[int]string{1: "one"}}},
|
|
{name: "nan", extraParams: map[string]any{"bad": math.NaN()}},
|
|
{name: "positive infinity", extraParams: map[string]any{"bad": math.Inf(1)}},
|
|
{name: "negative infinity", extraParams: map[string]any{"bad": math.Inf(-1)}},
|
|
{name: "cyclic map", extraParams: cyclicMap},
|
|
{name: "cyclic slice", extraParams: map[string]any{"cycle": cyclicSlice}},
|
|
{name: "malformed JSON number", extraParams: map[string]any{"value": json.Number("+1")}},
|
|
{name: "empty nested key", extraParams: map[string]any{"nested": map[string]any{"": true}}},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fake := &fakeLLMClient{response: &promptkit.GenerateResponse{Content: "ok"}}
|
|
engine := newContractEngineWithOptions(t, frameworkSchemaDir, promptkit.WithLLMClient(fake))
|
|
|
|
_, err := engine.Run(context.Background(), promptkit.RunRequest{
|
|
PromptID: frameworkMarkdownSummaryPromptID,
|
|
Inputs: map[string]promptkit.ArtifactRef{
|
|
"transcript": promptkit.Inline("Rin opens the gate."),
|
|
"glossary": promptkit.Inline("gate: A guarded passage."),
|
|
},
|
|
Execution: &promptkit.ExecutionTargetOverride{ExtraParams: tc.extraParams},
|
|
})
|
|
if !errors.Is(err, promptkit.ErrInvalidRequest) {
|
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
|
}
|
|
if len(fake.requests) != 0 {
|
|
t.Fatalf("expected invalid request to fail before LLM call, got %d requests", len(fake.requests))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWithLLMClientRejectsNilClient(t *testing.T) {
|
|
_, err := promptkit.NewEngine(contractConfig(frameworkSchemaDir), promptkit.WithLLMClient(nil))
|
|
if !errors.Is(err, promptkit.ErrInvalidConfig) {
|
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewEngineConstructsDefaultLLMClientWithoutCredentials(t *testing.T) {
|
|
if _, err := promptkit.NewEngine(contractConfig(frameworkSchemaDir)); err != nil {
|
|
t.Fatalf("expected default engine construction without credentials to succeed, got %v", err)
|
|
}
|
|
}
|
|
|
|
func newContractEngine(t *testing.T) *promptkit.Engine {
|
|
t.Helper()
|
|
|
|
for _, path := range []string{
|
|
frameworkPromptDir,
|
|
frameworkProfileDir,
|
|
frameworkSchemaDir,
|
|
} {
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("expected framework contract path %s to exist: %v", path, err)
|
|
}
|
|
}
|
|
|
|
engine, err := promptkit.NewEngine(contractConfig(frameworkSchemaDir))
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
return engine
|
|
}
|
|
|
|
func newContractEngineWithOptions(t *testing.T, schemaDir string, opts ...promptkit.Option) *promptkit.Engine {
|
|
t.Helper()
|
|
|
|
engine, err := promptkit.NewEngine(contractConfig(schemaDir), opts...)
|
|
if err != nil {
|
|
t.Fatalf("expected engine construction to succeed, got %v", err)
|
|
}
|
|
return engine
|
|
}
|
|
|
|
func newArtifactReaderEngine(t *testing.T, reader promptkit.ArtifactReader) *promptkit.Engine {
|
|
t.Helper()
|
|
|
|
promptDir := t.TempDir()
|
|
writePublicPromptFile(t, promptDir, "artifact-reader", frameworkFastProfileID)
|
|
engine, err := promptkit.NewEngine(promptkit.Config{
|
|
PromptDir: promptDir,
|
|
ProfileDir: frameworkProfileDir,
|
|
SchemaDir: frameworkSchemaDir,
|
|
}, promptkit.WithArtifactReader(reader))
|
|
if err != nil {
|
|
t.Fatalf("construct engine with artifact reader: %v", err)
|
|
}
|
|
return engine
|
|
}
|
|
|
|
func contractConfig(schemaDir string) promptkit.Config {
|
|
return promptkit.Config{
|
|
PromptDir: frameworkPromptDir,
|
|
ProfileDir: frameworkProfileDir,
|
|
SchemaDir: schemaDir,
|
|
}
|
|
}
|
|
|
|
func writePublicPromptFile(t *testing.T, dir, id, defaultProfile string) {
|
|
t.Helper()
|
|
data := `id: ` + id + `
|
|
version: "1.0.0"
|
|
default_profile: ` + defaultProfile + `
|
|
inputs:
|
|
- name: transcript
|
|
required: true
|
|
messages:
|
|
- role: user
|
|
content: "Summarize: {{input \"transcript\"}}"
|
|
output:
|
|
format: text
|
|
validation_mode: none
|
|
repair_attempts: 0
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(data), 0o644); err != nil {
|
|
t.Fatalf("failed to write prompt fixture: %v", err)
|
|
}
|
|
}
|
|
|
|
func writePublicProfileFile(t *testing.T, dir, id, endpoint, model string) {
|
|
t.Helper()
|
|
data := `id: ` + id + `
|
|
endpoint: ` + endpoint + `
|
|
model: ` + model + `
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(data), 0o644); err != nil {
|
|
t.Fatalf("failed to write profile fixture: %v", err)
|
|
}
|
|
}
|
|
|
|
func writePublicProfileFileWithAPIKeyEnv(t *testing.T, dir, id, endpoint, model, apiKeyEnv string) {
|
|
t.Helper()
|
|
data := `id: ` + id + `
|
|
endpoint: ` + endpoint + `
|
|
model: ` + model + `
|
|
api_key_env: ` + apiKeyEnv + `
|
|
`
|
|
if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(data), 0o644); err != nil {
|
|
t.Fatalf("failed to write profile fixture: %v", err)
|
|
}
|
|
}
|
|
|
|
func publicStructuredPromptFS(id string, schemaPath string) fstest.MapFS {
|
|
return fstest.MapFS{
|
|
"prompts/prompt.yaml": &fstest.MapFile{Data: []byte(`id: ` + id + `
|
|
version: "1.0.0"
|
|
default_profile: contract-fast
|
|
inputs:
|
|
- name: transcript
|
|
required: true
|
|
messages:
|
|
- role: user
|
|
content: "Extract events from {{input \"transcript\"}}."
|
|
output:
|
|
format: json
|
|
validation_mode: json_schema
|
|
schema_path: ` + schemaPath + `
|
|
repair_attempts: 0
|
|
`)},
|
|
}
|
|
}
|
|
|
|
func publicSchemaFS() fstest.MapFS {
|
|
return fstest.MapFS{
|
|
"schemas/events.schema.json": &fstest.MapFile{Data: []byte(publicSchemaJSON())},
|
|
}
|
|
}
|
|
|
|
func publicSchemaJSON() string {
|
|
return `{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"type": "object",
|
|
"required": ["events"],
|
|
"properties": {
|
|
"events": {"type": "array"}
|
|
}
|
|
}`
|
|
}
|
|
|
|
type executionProfileFixture struct {
|
|
id string
|
|
endpoint string
|
|
model string
|
|
temperature float64
|
|
maxTokens int
|
|
topP float64
|
|
timeoutSeconds int
|
|
serviceTier string
|
|
reasoningEffort string
|
|
apiKeyEnv string
|
|
extraParamSource string
|
|
}
|
|
|
|
func executionTargetFromProfileFixture(profile executionProfileFixture) promptkit.ExecutionTarget {
|
|
return promptkit.ExecutionTarget{
|
|
Endpoint: profile.endpoint,
|
|
Model: profile.model,
|
|
Temperature: profile.temperature,
|
|
MaxTokens: profile.maxTokens,
|
|
TopP: profile.topP,
|
|
TimeoutSeconds: profile.timeoutSeconds,
|
|
ServiceTier: profile.serviceTier,
|
|
ReasoningEffort: profile.reasoningEffort,
|
|
APIKeyEnv: profile.apiKeyEnv,
|
|
ExtraParams: map[string]any{"source": profile.extraParamSource},
|
|
}
|
|
}
|
|
|
|
func writeExecutionProfileFixture(t *testing.T, dir string, profile executionProfileFixture) {
|
|
t.Helper()
|
|
data := fmt.Sprintf(`id: %s
|
|
endpoint: %s
|
|
model: %s
|
|
temperature: %g
|
|
max_tokens: %d
|
|
top_p: %g
|
|
timeout_seconds: %d
|
|
service_tier: %s
|
|
reasoning_effort: %s
|
|
api_key_env: %s
|
|
extra_params:
|
|
source: %q
|
|
`,
|
|
profile.id,
|
|
profile.endpoint,
|
|
profile.model,
|
|
profile.temperature,
|
|
profile.maxTokens,
|
|
profile.topP,
|
|
profile.timeoutSeconds,
|
|
profile.serviceTier,
|
|
profile.reasoningEffort,
|
|
profile.apiKeyEnv,
|
|
profile.extraParamSource,
|
|
)
|
|
if err := os.WriteFile(filepath.Join(dir, profile.id+".yaml"), []byte(data), 0o644); err != nil {
|
|
t.Fatalf("write execution profile fixture: %v", err)
|
|
}
|
|
}
|
|
|
|
type fakeLLMClient struct {
|
|
response *promptkit.GenerateResponse
|
|
err error
|
|
requests []promptkit.GenerateRequest
|
|
}
|
|
|
|
type recordingArtifactReader struct {
|
|
artifact *promptkit.Artifact
|
|
err error
|
|
refs []promptkit.ArtifactRef
|
|
read func(context.Context, promptkit.ArtifactRef) (*promptkit.Artifact, error)
|
|
}
|
|
|
|
func (r *recordingArtifactReader) Read(ctx context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
|
|
r.refs = append(r.refs, ref)
|
|
if r.read != nil {
|
|
return r.read(ctx, ref)
|
|
}
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
return r.artifact, nil
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
func (f *fakeLLMClient) Generate(_ context.Context, req promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
|
|
f.requests = append(f.requests, req)
|
|
if f.err != nil {
|
|
return nil, f.err
|
|
}
|
|
return f.response, nil
|
|
}
|