Files
weatherreporter/internal/adapters/promptkit/adapter_test.go

909 lines
32 KiB
Go

package promptkitadapter
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
promptkit "gitea.maximumdirect.net/eric/promptkit"
"gitea.maximumdirect.net/eric/weatherreporter/internal/generatedtext"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptexec"
)
type fakeClient struct {
mu sync.Mutex
response *promptkit.GenerateResponse
err error
outcomes []generationOutcome
next int
calls int
requests []promptkit.GenerateRequest
block bool
started chan struct{}
}
type generationOutcome struct {
response *promptkit.GenerateResponse
err error
}
type recordingReader struct {
ref promptkit.ArtifactRef
}
func (reader *recordingReader) Read(_ context.Context, ref promptkit.ArtifactRef) (*promptkit.Artifact, error) {
reader.ref = ref
return &promptkit.Artifact{
Name: "data_package",
ContentType: "application/yaml",
Body: []byte(ref.Body),
URI: ref.URI,
Hash: "input-hash",
}, nil
}
func (client *fakeClient) Generate(ctx context.Context, request promptkit.GenerateRequest) (*promptkit.GenerateResponse, error) {
client.mu.Lock()
client.calls++
client.requests = append(client.requests, request)
block := client.block
started := client.started
response := client.response
err := client.err
if client.next < len(client.outcomes) {
outcome := client.outcomes[client.next]
client.next++
response, err = outcome.response, outcome.err
}
client.mu.Unlock()
if started != nil {
started <- struct{}{}
}
if block {
<-ctx.Done()
return nil, ctx.Err()
}
return response, err
}
func TestExecuteSupportsConcurrentCalls(t *testing.T) {
client := &fakeClient{response: validResponse(), block: true, started: make(chan struct{}, 2)}
adapter := newTestAdapter(t, client)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
executionErrors := make(chan error, 2)
for range 2 {
go func() {
_, err := adapter.Execute(ctx, testExecuteRequest(), nil)
executionErrors <- err
}()
}
for range 2 {
select {
case <-client.started:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for concurrent Promptkit calls")
}
}
cancel()
for range 2 {
if err := <-executionErrors; promptexec.CategoryOf(err) != promptexec.Canceled {
t.Fatalf("Execute() error/category = %v/%q", err, promptexec.CategoryOf(err))
}
}
if client.callCount() != 2 {
t.Fatalf("provider calls = %d, want 2", client.callCount())
}
}
func (client *fakeClient) callCount() int {
client.mu.Lock()
defer client.mu.Unlock()
return client.calls
}
func (client *fakeClient) request() promptkit.GenerateRequest {
client.mu.Lock()
defer client.mu.Unlock()
return client.requests[0]
}
func (client *fakeClient) allRequests() []promptkit.GenerateRequest {
client.mu.Lock()
defer client.mu.Unlock()
return append([]promptkit.GenerateRequest(nil), client.requests...)
}
func TestInspectPromptAndProfile(t *testing.T) {
adapter := newTestAdapter(t, &fakeClient{})
inspection, err := adapter.InspectPrompt(context.Background(), "weather.daily_generated_text", "2.1.0")
if err != nil {
t.Fatalf("InspectPrompt() error = %v", err)
}
if inspection.PromptID != "weather.daily_generated_text" || inspection.PromptVersion != "2.1.0" || inspection.DefaultProfileID != "weather-balanced" || inspection.Output.RepairAttempts != 1 {
t.Fatalf("inspection = %#v", inspection)
}
if len(inspection.Inputs) != 1 || inspection.Inputs[0].Name != "data_package" || !inspection.Inputs[0].Required || inspection.Inputs[0].ContentType != "application/yaml" {
t.Fatalf("inputs = %#v", inspection.Inputs)
}
if inspection.Output.Format != "json" || inspection.Output.ValidationMode != "json_schema" || inspection.Output.SchemaPath != "daily.generated_text.schema.json" {
t.Fatalf("output = %#v", inspection.Output)
}
profile, err := adapter.InspectProfile(context.Background(), "test-profile")
if err != nil {
t.Fatalf("InspectProfile() error = %v", err)
}
if profile.ProfileID != "test-profile" || profile.BackendID != "" || profile.ModelName != "test-model" || profile.CredentialRequired {
t.Fatalf("profile = %#v", profile)
}
if strings.Contains(fmt.Sprintf("%#v", profile), "https://profile.example") {
t.Fatalf("profile leaks endpoint: %#v", profile)
}
builtin, err := adapter.InspectProfile(context.Background(), "gemini-flash-latest")
if err != nil {
t.Fatalf("InspectProfile(builtin) error = %v", err)
}
if builtin.ProfileID != "gemini-flash-latest" || builtin.ModelName == "" {
t.Fatalf("builtin profile = %#v", builtin)
}
}
func TestEmbeddedProfilesAreAvailableToProductionAndTestAdapters(t *testing.T) {
adapter, err := New(Config{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
for _, want := range []struct {
id string
backend string
model string
}{
{"weather-light", "openrouter", "deepseek/deepseek-v4-flash"},
{"weather-balanced", "openrouter", "~google/gemini-flash-latest"},
{"weather-deep", "openrouter", "~anthropic/claude-sonnet-latest"},
} {
t.Run(want.id, func(t *testing.T) {
assertProfile(t, adapter, want.id, want.backend, want.model)
})
}
testAdapter, err := newAdapterForTest(Config{}, &fakeClient{})
if err != nil {
t.Fatalf("newAdapterForTest() error = %v", err)
}
assertProfile(t, testAdapter, "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
}
func TestConfiguredProfilesOverrideEmbeddedFallbacks(t *testing.T) {
file := writeProfileFile(t, `id: weather-light
endpoint: https://local-file.example/v1
model: file-light
`)
fileAdapter, err := New(Config{ProfileFile: file})
if err != nil {
t.Fatalf("New(profile file) error = %v", err)
}
assertProfile(t, fileAdapter, "weather-light", "", "file-light")
directory := testProfileDirectory(t, map[string]string{"profile.yml": `id: weather-light
backend: local
model: directory-light
`})
directoryAdapter, err := New(Config{ProfileDirectory: directory, LocalEndpoint: "https://local-directory.example/v1"})
if err != nil {
t.Fatalf("New(profile directory) error = %v", err)
}
assertProfile(t, directoryAdapter, "weather-light", promptkit.BackendLocal, "directory-light")
derived := writeProfileFile(t, `id: weather-light
base_profile: gemini-flash-latest
`)
derivedAdapter, err := New(Config{ProfileFile: derived})
if err != nil {
t.Fatalf("New(derived profile) error = %v", err)
}
assertProfile(t, derivedAdapter, "weather-light", "openrouter", "~google/gemini-flash-latest")
}
func TestConfiguredBaseProfileOverridesEmbeddedProfileTarget(t *testing.T) {
directory := testProfileDirectory(t, map[string]string{
"deepseek.yml": `id: deepseek-4-flash
backend: local
model: shadowed-deepseek
`,
})
adapter, err := New(Config{ProfileDirectory: directory, LocalEndpoint: "https://local-directory.example/v1"})
if err != nil {
t.Fatalf("New() error = %v", err)
}
assertProfile(t, adapter, "weather-light", promptkit.BackendLocal, "shadowed-deepseek")
}
func TestMaintainedWeatherLightLocalProfileExampleInspectsOffline(t *testing.T) {
adapter, err := New(Config{ProfileFile: filepath.Join("..", "..", "..", "examples", "weather-light-local-profile.yml")})
if err != nil {
t.Fatalf("New() error = %v", err)
}
assertProfile(t, adapter, "weather-light", "", "weather-local")
}
func TestProfileResolutionFallsThroughOnlyWhenTheConfiguredIDIsAbsent(t *testing.T) {
absentAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, map[string]string{"profile.yml": `id: other-profile
backend: openrouter
model: other-model
`})})
if err != nil {
t.Fatalf("New(absent profile) error = %v", err)
}
assertProfile(t, absentAdapter, "weather-light", "openrouter", "deepseek/deepseek-v4-flash")
malformedAdapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, map[string]string{"profile.yml": `id: weather-light
backend: openrouter
`})})
if err != nil {
t.Fatalf("New(malformed profile) error = %v", err)
}
if _, err := malformedAdapter.InspectProfile(context.Background(), "weather-light"); err == nil {
t.Fatal("InspectProfile() error = nil, want malformed configured profile error")
}
}
func TestProfileResolutionReturnsConfiguredInheritanceFailures(t *testing.T) {
tests := []struct {
name string
profile string
profiles map[string]string
}{
{
name: "missing base",
profile: "missing-base",
profiles: map[string]string{"missing.yml": `id: missing-base
base_profile: unavailable
`},
},
{
name: "cyclic bases",
profile: "first",
profiles: map[string]string{
"first.yml": `id: first
base_profile: second
`,
"second.yml": `id: second
base_profile: first
`,
},
},
{
name: "malformed base",
profile: "child",
profiles: map[string]string{
"child.yml": `id: child
base_profile: malformed
`,
"malformed.yml": `id: malformed
base_profile: [not-a-profile]
`,
},
},
{
name: "incomplete target",
profile: "incomplete",
profiles: map[string]string{"incomplete.yml": `id: incomplete
backend: openrouter
`},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter, err := New(Config{ProfileDirectory: testProfileDirectory(t, test.profiles)})
if err != nil {
t.Fatalf("New() error = %v", err)
}
if _, err := adapter.InspectProfile(context.Background(), test.profile); err == nil {
t.Fatal("InspectProfile() error = nil, want configured inheritance error")
}
})
}
}
func TestRakestrawhomeBuiltInProfileInspectsOffline(t *testing.T) {
adapter, err := New(Config{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
profile, err := adapter.InspectProfile(context.Background(), "rakestrawhome-gemma-4-31b")
if err != nil {
t.Fatalf("InspectProfile() error = %v", err)
}
if profile.ProfileID != "rakestrawhome-gemma-4-31b" || profile.BackendID != "rakestrawhome" || profile.ModelName == "" {
t.Fatalf("profile = %#v", profile)
}
}
func TestProfileResolutionPreservesBuiltInAndExplicitPrecedence(t *testing.T) {
adapter, err := New(Config{})
if err != nil {
t.Fatalf("New() error = %v", err)
}
builtin, err := adapter.InspectProfile(context.Background(), "gemini-flash-latest")
if err != nil {
t.Fatalf("InspectProfile(builtin) error = %v", err)
}
if builtin.ProfileID != "gemini-flash-latest" || builtin.BackendID != "openrouter" || builtin.ModelName == "" {
t.Fatalf("builtin profile = %#v", builtin)
}
explicit, err := newAdapter(Config{}, promptkit.WithProfiles(promptkit.Profile{
ID: "weather-light",
Endpoint: "https://explicit.example/v1",
Model: "explicit-light",
}))
if err != nil {
t.Fatalf("newAdapter(explicit profile) error = %v", err)
}
assertProfile(t, explicit, "weather-light", "", "explicit-light")
}
func TestExecuteUsesPreparedInlineDataPackage(t *testing.T) {
client := &fakeClient{response: validResponse()}
adapter := newTestAdapter(t, client)
request := testExecuteRequest()
callbackCalls := 0
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
callbackCalls++
if preparation.PromptID != request.PromptID || preparation.PromptVersion != request.PromptVersion || preparation.ModelName != "test-model" {
t.Fatalf("preparation = %#v", preparation)
}
if debug != nil {
t.Fatalf("debug = %#v, want nil", debug)
}
if client.callCount() != 0 {
t.Fatal("provider called before preparation callback")
}
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if callbackCalls != 1 || client.callCount() != 1 {
t.Fatalf("callback/provider calls = %d/%d, want 1/1", callbackCalls, client.callCount())
}
if result == nil || result.Validation.Status != promptexec.ValidationPassed || string(result.RawOutput) != client.response.Content {
t.Fatalf("result = %#v", result)
}
if result.Debug != nil {
t.Fatalf("debug = %#v, want nil", result.Debug)
}
providerRequest := client.request()
if providerRequest.Target.Model != "test-model" || providerRequest.Target.Endpoint != "https://profile.example/v1" {
t.Fatalf("provider target = %#v", providerRequest.Target)
}
if len(providerRequest.Prompt.Messages) == 0 || !strings.Contains(providerRequest.Prompt.Messages[2].Content, string(request.DataPackage)) {
t.Fatalf("rendered messages do not contain exact data package: %#v", providerRequest.Prompt.Messages)
}
}
func TestExecuteEmbeddedHourlyProfileThroughPreparedPath(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
client := &fakeClient{response: hourlyValidResponse()}
adapter, err := newAdapter(Config{}, promptkit.WithLLMClient(client))
if err != nil {
t.Fatalf("newAdapter() error = %v", err)
}
request := promptexec.ExecuteRequest{
PromptID: "weather.hourly_generated_text",
PromptVersion: "2.1.0",
ProfileID: "weather-light",
DataPackage: []byte("report:\n id: hourly\nbriefing: {}\n"),
}
var preparation promptexec.Preparation
prepared := false
result, err := adapter.Execute(context.Background(), request, func(value promptexec.Preparation, _ *promptexec.PreparationDebug) error {
if client.callCount() != 0 {
t.Fatal("provider was called before preparation completed")
}
preparation = value
prepared = true
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !prepared || preparation.ProfileID != "weather-light" || preparation.BackendID != "openrouter" || preparation.ModelName != "deepseek/deepseek-v4-flash" {
t.Fatalf("preparation = %#v", preparation)
}
if result == nil || result.ProfileID != "weather-light" || result.BackendID != "openrouter" || result.ModelName != "deepseek/deepseek-v4-flash" || result.Validation.Status != promptexec.ValidationPassed {
t.Fatalf("execution = %#v", result)
}
if client.callCount() != 1 || client.request().Target.Model != "deepseek/deepseek-v4-flash" {
t.Fatalf("provider calls/request = %d/%#v", client.callCount(), client.request())
}
}
func TestExecuteUsesExactInlineDataPackageProvenance(t *testing.T) {
client := &fakeClient{response: validResponse()}
reader := &recordingReader{}
adapter := newTestAdapterWithOptions(t, client, promptkit.WithArtifactReader(reader))
request := testExecuteRequest()
if _, err := adapter.Execute(context.Background(), request, nil); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if reader.ref.Type != promptkit.ArtifactRefInline || reader.ref.URI != "" || reader.ref.Body != string(request.DataPackage) {
t.Fatalf("artifact ref = %#v, want exact inline data package provenance", reader.ref)
}
}
func TestExecuteCapturesSensitiveDebugOnlyWhenRequested(t *testing.T) {
client := &fakeClient{response: validResponse()}
adapter := newTestAdapter(t, client)
request := testExecuteRequest()
request.CaptureDebug = true
var preparationDebug *promptexec.PreparationDebug
result, err := adapter.Execute(context.Background(), request, func(preparation promptexec.Preparation, debug *promptexec.PreparationDebug) error {
preparationDebug = debug
if strings.Contains(fmt.Sprintf("%#v", preparation), "https://profile.example") || strings.Contains(fmt.Sprintf("%#v", preparation), string(request.DataPackage)) {
t.Fatalf("safe preparation leaks sensitive content: %#v", preparation)
}
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if preparationDebug == nil || preparationDebug.Endpoint != "https://profile.example/v1" || len(preparationDebug.RenderedMessages) == 0 || len(preparationDebug.StructuredSchema) == 0 || len(preparationDebug.ParametersJSON) == 0 {
t.Fatalf("preparation debug = %#v", preparationDebug)
}
if result.Debug == nil || string(result.Debug.RawOutput) != client.response.Content {
t.Fatalf("execution debug = %#v", result.Debug)
}
}
func TestMarshalDebugParametersOmitsProviderExtras(t *testing.T) {
const marker = "private-debug-marker"
parameters := string(marshalDebugParameters(promptkit.ExecutionTarget{
Temperature: 0.2,
MaxTokens: 400,
TopP: 0.9,
TimeoutSeconds: 30,
ServiceTier: "flex",
ReasoningEffort: "high",
ExtraParams: map[string]any{
"access-key": marker,
"signature": marker,
},
}))
if strings.Contains(parameters, marker) || strings.Contains(parameters, "extra_params") {
t.Fatalf("debug parameters leaked provider extras: %s", parameters)
}
for _, want := range []string{`"temperature":0.2`, `"max_tokens":400`, `"top_p":0.9`, `"timeout_seconds":30`, `"service_tier":"flex"`, `"reasoning_effort":"high"`} {
if !strings.Contains(parameters, want) {
t.Fatalf("debug parameters missing safe value %q: %s", want, parameters)
}
}
}
func TestExecuteCallbackFailurePreventsGeneration(t *testing.T) {
client := &fakeClient{response: validResponse()}
adapter := newTestAdapter(t, client)
callbackError := errors.New("save preparation")
result, err := adapter.Execute(context.Background(), testExecuteRequest(), func(promptexec.Preparation, *promptexec.PreparationDebug) error {
return callbackError
})
if result != nil || !errors.Is(err, callbackError) || client.callCount() != 0 {
t.Fatalf("result/error/provider calls = %#v/%v/%d", result, err, client.callCount())
}
}
func TestExecuteReturnsCompletedValidationRejection(t *testing.T) {
client := &fakeClient{response: &promptkit.GenerateResponse{Content: `{"summary":42}`, Usage: promptkit.TokenUsage{TotalTokens: 5}}}
adapter := newTestAdapter(t, client)
result, err := adapter.Execute(context.Background(), testExecuteRequest(), nil)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.Validation.Diagnostics) == 0 || string(result.RawOutput) != client.response.Content {
t.Fatalf("result = %#v", result)
}
}
func TestExecuteMapsCorrectiveGenerationResults(t *testing.T) {
valid := `{"summary":"valid"}`
invalid := `{"summary":42}`
tests := []struct {
name string
outcomes []generationOutcome
wantStatus promptexec.ValidationStatus
wantRepairs int
wantCalls int
wantRaw string
wantUsage promptexec.TokenUsage
}{
{
name: "first pass valid",
outcomes: []generationOutcome{{response: generationResponse(valid, 2, 3, 5)}},
wantStatus: promptexec.ValidationPassed,
wantRepairs: 0,
wantCalls: 1,
wantRaw: valid,
wantUsage: promptexec.TokenUsage{PromptTokens: 2, CompletionTokens: 3, TotalTokens: 5},
},
{
name: "empty output repaired",
outcomes: []generationOutcome{{response: generationResponse("", 2, 3, 5)}, {response: generationResponse(valid, 7, 11, 18)}},
wantStatus: promptexec.ValidationPassed,
wantRepairs: 1,
wantCalls: 2,
wantRaw: valid,
wantUsage: promptexec.TokenUsage{PromptTokens: 9, CompletionTokens: 14, TotalTokens: 23},
},
{
name: "invalid output repaired",
outcomes: []generationOutcome{{response: generationResponse(invalid, 2, 3, 5)}, {response: generationResponse(valid, 7, 11, 18)}},
wantStatus: promptexec.ValidationPassed,
wantRepairs: 1,
wantCalls: 2,
wantRaw: valid,
wantUsage: promptexec.TokenUsage{PromptTokens: 9, CompletionTokens: 14, TotalTokens: 23},
},
{
name: "repair budget exhausted",
outcomes: []generationOutcome{{response: generationResponse(invalid, 2, 3, 5)}, {response: generationResponse(invalid, 7, 11, 18)}},
wantStatus: promptexec.ValidationFailed,
wantRepairs: 1,
wantCalls: 2,
wantRaw: invalid,
wantUsage: promptexec.TokenUsage{PromptTokens: 9, CompletionTokens: 14, TotalTokens: 23},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
client := &fakeClient{outcomes: test.outcomes}
adapter := newRepairAdapter(t, client, "https://repair.example/v1")
var preparation promptexec.Preparation
result, err := adapter.Execute(context.Background(), repairExecuteRequest(), func(value promptexec.Preparation, _ *promptexec.PreparationDebug) error {
preparation = value
return nil
})
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if preparation.Output.RepairAttempts != 1 || result == nil || result.Validation.Status != test.wantStatus || result.Validation.RepairAttempts != test.wantRepairs || string(result.RawOutput) != test.wantRaw || result.Usage != test.wantUsage {
t.Fatalf("preparation/result = %#v/%#v", preparation, result)
}
requests := client.allRequests()
if len(requests) != test.wantCalls {
t.Fatalf("provider requests = %d, want %d", len(requests), test.wantCalls)
}
if test.wantCalls == 2 && !reflect.DeepEqual(requests[0].Target, requests[1].Target) {
t.Fatalf("corrective target = %#v, want same prepared identity as %#v", requests[1].Target, requests[0].Target)
}
if result.ProfileID != preparation.ProfileID || result.BackendID != preparation.BackendID || result.ModelName != preparation.ModelName || result.PromptID != preparation.PromptID || result.PromptVersion != preparation.PromptVersion || result.PromptHash != preparation.PromptHash {
t.Fatalf("prepared/result identity = %#v/%#v", preparation, result)
}
})
}
}
func TestExecuteMapsCorrectiveGenerationError(t *testing.T) {
const providerBody = `{"error":{"code":"repair-code","type":"repair-type","message":"repair-message"}}`
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
calls++
if calls == 1 {
writer.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprintf(writer, `{"choices":[{"message":{"content":%q}}],"usage":{"prompt_tokens":2,"completion_tokens":3,"total_tokens":5}}`, `{"summary":42}`)
return
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusUnprocessableEntity)
_, _ = writer.Write([]byte(providerBody))
}))
defer server.Close()
adapter := newRepairAdapter(t, nil, server.URL)
result, err := adapter.Execute(context.Background(), repairExecuteRequest(), nil)
if result != nil || err == nil || calls != 2 {
t.Fatalf("result/error/calls = %#v/%v/%d", result, err, calls)
}
var generationError *promptexec.GenerationError
if !errors.As(err, &generationError) || generationError.StatusCode() != http.StatusUnprocessableEntity || generationError.ProviderCode() != "repair-code" || generationError.ProviderType() != "repair-type" || generationError.ProviderMessage() != "repair-message" {
t.Fatalf("generation error = %#v", err)
}
if strings.Contains(err.Error(), "repair-message") || !errors.Is(err, promptkit.ErrLLMGenerate) {
t.Fatalf("generation error = %v", err)
}
}
func TestExecuteDropsOversizedGeneratedOutput(t *testing.T) {
client := &fakeClient{response: &promptkit.GenerateResponse{Content: strings.Repeat("x", generatedtext.MaxGeneratedTextBytes+1)}}
adapter := newTestAdapter(t, client)
request := testExecuteRequest()
request.CaptureDebug = true
result, err := adapter.Execute(context.Background(), request, nil)
if err != nil {
t.Fatalf("Execute() error = %v", err)
}
if result == nil || result.Validation.Status != promptexec.ValidationFailed || len(result.RawOutput) != 0 || result.Debug == nil || len(result.Debug.RawOutput) != 0 {
t.Fatalf("execution = %#v", result)
}
if len(result.Validation.Diagnostics) != 1 || result.Validation.Diagnostics[0] != "generated output exceeds the configured size limit" {
t.Fatalf("diagnostics = %#v", result.Validation.Diagnostics)
}
}
func TestExecuteClassifiesOperationalFailures(t *testing.T) {
tests := []struct {
name string
client *fakeClient
context func() (context.Context, context.CancelFunc)
category promptexec.ErrorCategory
}{
{
name: "generation",
client: &fakeClient{err: errors.New("provider response body")},
context: func() (context.Context, context.CancelFunc) {
return context.WithCancel(context.Background())
},
category: promptexec.Generation,
},
{
name: "canceled",
client: &fakeClient{block: true},
context: func() (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx, func() {}
},
category: promptexec.Canceled,
},
{
name: "deadline",
client: &fakeClient{block: true},
context: func() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), time.Nanosecond)
},
category: promptexec.DeadlineExceeded,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter := newTestAdapter(t, test.client)
ctx, cancel := test.context()
defer cancel()
result, err := adapter.Execute(ctx, testExecuteRequest(), nil)
if result != nil || err == nil || promptexec.CategoryOf(err) != test.category {
t.Fatalf("result/error/category = %#v/%v/%q, want %q", result, err, promptexec.CategoryOf(err), test.category)
}
if strings.Contains(err.Error(), "provider response body") {
t.Fatalf("error leaks provider detail: %v", err)
}
})
}
}
func TestClassifyPromptkitErrors(t *testing.T) {
tests := []struct {
err error
category promptexec.ErrorCategory
}{
{promptkit.ErrInvalidConfig, promptexec.InvalidConfiguration},
{promptkit.ErrInvalidRequest, promptexec.InvalidRequest},
{promptkit.ErrPromptNotFound, promptexec.PromptNotFound},
{promptkit.ErrPromptLoad, promptexec.PromptLoad},
{promptkit.ErrProfileNotFound, promptexec.ProfileNotFound},
{promptkit.ErrProfileLoad, promptexec.ProfileLoad},
{promptkit.ErrAPIKeyEnvMissing, promptexec.MissingCredential},
{promptkit.ErrArtifactLoad, promptexec.ArtifactLoad},
{promptkit.ErrPromptRender, promptexec.PromptRender},
{promptkit.ErrLLMGenerate, promptexec.Generation},
{promptkit.ErrValidation, promptexec.OperationalValidation},
{&promptkit.CapacityError{BackendID: "local"}, promptexec.Capacity},
}
for _, test := range tests {
t.Run(string(test.category), func(t *testing.T) {
got := classifyError(test.err)
if promptexec.CategoryOf(got) != test.category {
t.Fatalf("category = %q, want %q", promptexec.CategoryOf(got), test.category)
}
})
}
}
func TestNewValidatesConfiguration(t *testing.T) {
if _, err := New(Config{ProfileDirectory: "profiles", ProfileFile: "profile.yml"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("profile source error = %v", err)
}
if _, err := New(Config{LocalConcurrencyLimit: 1}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("local concurrency error = %v", err)
}
if _, err := New(Config{LocalEndpoint: "not a URL"}); promptexec.CategoryOf(err) != promptexec.InvalidConfiguration {
t.Fatalf("local endpoint error = %v", err)
}
}
func TestLocalBackendAndOptionalCredentialSourceBehavior(t *testing.T) {
t.Setenv("WEATHERREPORTER_TEST_MISSING_KEY", "")
profiles := testProfileDirectory(t, map[string]string{"profile.yml": `id: local-profile
backend: local
model: local-model
`})
adapter, err := newAdapterForTest(Config{
ProfileDirectory: profiles,
LocalEndpoint: "https://local.example/v1",
LocalConcurrencyLimit: 1,
}, &fakeClient{})
if err != nil {
t.Fatalf("newAdapterForTest(local) error = %v", err)
}
profile, err := adapter.InspectProfile(context.Background(), "local-profile")
if err != nil || profile.BackendID != promptkit.BackendLocal || profile.ModelName != "local-model" {
t.Fatalf("local profile/error = %#v/%v", profile, err)
}
if got := classifyError(&promptkit.CapacityError{BackendID: promptkit.BackendLocal}); promptexec.CategoryOf(got) != promptexec.Capacity {
t.Fatalf("capacity classification = %v", got)
}
credentialProfiles := testProfileDirectory(t, map[string]string{"profile.yml": `id: credential-profile
endpoint: https://profile.example/v1
model: test-model
api_key_env: WEATHERREPORTER_TEST_MISSING_KEY
`})
client := &fakeClient{response: validResponse()}
credentialAdapter, err := newAdapterForTest(Config{ProfileDirectory: credentialProfiles}, client)
if err != nil {
t.Fatalf("newAdapterForTest(credential) error = %v", err)
}
credentialProfile, err := credentialAdapter.InspectProfile(context.Background(), "credential-profile")
if err != nil || credentialProfile.CredentialRequired || credentialProfile.APIKeyEnv != "WEATHERREPORTER_TEST_MISSING_KEY" {
t.Fatalf("credential profile/error = %#v/%v", credentialProfile, err)
}
request := testExecuteRequest()
request.ProfileID = "credential-profile"
result, err := credentialAdapter.Execute(context.Background(), request, nil)
if err != nil || result == nil || client.callCount() != 1 {
t.Fatalf("credential result/error/calls = %#v/%v/%d", result, err, client.callCount())
}
}
func newTestAdapter(t *testing.T, client promptkit.LLMClient) *Adapter {
return newTestAdapterWithOptions(t, client)
}
func assertProfile(t *testing.T, adapter *Adapter, id string, backend string, model string) {
t.Helper()
profile, err := adapter.InspectProfile(context.Background(), id)
if err != nil {
t.Fatalf("InspectProfile(%q) error = %v", id, err)
}
if profile.ProfileID != id || profile.BackendID != backend || profile.ModelName != model {
t.Fatalf("profile = %#v, want %q with backend/model %q/%q", profile, id, backend, model)
}
}
func newTestAdapterWithOptions(t *testing.T, client promptkit.LLMClient, options ...promptkit.Option) *Adapter {
t.Helper()
profiles := testProfileDirectory(t, map[string]string{"profile.yml": `id: test-profile
endpoint: https://profile.example/v1
model: test-model
temperature: 0.2
max_tokens: 300
top_p: 1
timeout_seconds: 30
`})
options = append(options, promptkit.WithLLMClient(client))
adapter, err := newAdapter(Config{ProfileDirectory: profiles, Timeout: time.Second}, options...)
if err != nil {
t.Fatalf("newAdapter() error = %v", err)
}
return adapter
}
func testProfileDirectory(t *testing.T, profiles map[string]string) string {
t.Helper()
directory := t.TempDir()
for name, profile := range profiles {
if err := os.WriteFile(filepath.Join(directory, name), []byte(profile), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
}
return directory
}
func writeProfileFile(t *testing.T, profile string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "profile.yml")
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
t.Fatalf("write profile: %v", err)
}
return path
}
func testExecuteRequest() promptexec.ExecuteRequest {
return promptexec.ExecuteRequest{
PromptID: "weather.daily_generated_text",
PromptVersion: "2.1.0",
ProfileID: "test-profile",
DataPackage: []byte("report:\n id: daily\nbriefing: {}\n"),
}
}
func validResponse() *promptkit.GenerateResponse {
return &promptkit.GenerateResponse{
Content: `{"summary":"A quiet day is expected.","forecast_discussion":["High pressure keeps conditions settled."],"precipitation_timing":""}`,
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
}
}
func generationResponse(content string, promptTokens int, completionTokens int, totalTokens int) *promptkit.GenerateResponse {
return &promptkit.GenerateResponse{
Content: content,
Usage: promptkit.TokenUsage{
PromptTokens: promptTokens,
CompletionTokens: completionTokens,
TotalTokens: totalTokens,
},
}
}
func newRepairAdapter(t *testing.T, client promptkit.LLMClient, endpoint string) *Adapter {
t.Helper()
profiles := testProfileDirectory(t, map[string]string{"profile.yml": "id: repair-profile\nendpoint: " + endpoint + "\nmodel: repair-model\n"})
options := []promptkit.Option{
promptkit.WithPromptFS(fstest.MapFS{
"repair.yml": &fstest.MapFile{Data: []byte(`id: weather.repair
version: "1.0.0"
default_profile: repair-profile
inputs:
- name: data_package
required: true
content_type: application/yaml
messages:
- role: user
content: "{{input \"data_package\"}}"
output:
format: json
validation_mode: json_schema
schema_path: repair.schema.json
repair_attempts: 1
`)}}, "."),
promptkit.WithSchemaFS(fstest.MapFS{
"repair.schema.json": &fstest.MapFile{Data: []byte(`{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"],"additionalProperties":false}`)},
}, "."),
}
if client != nil {
options = append(options, promptkit.WithLLMClient(client))
}
adapter, err := newAdapter(Config{ProfileDirectory: profiles}, options...)
if err != nil {
t.Fatalf("newAdapter() error = %v", err)
}
return adapter
}
func repairExecuteRequest() promptexec.ExecuteRequest {
return promptexec.ExecuteRequest{
PromptID: "weather.repair",
PromptVersion: "1.0.0",
ProfileID: "repair-profile",
DataPackage: []byte("report: repair\n"),
}
}
func hourlyValidResponse() *promptkit.GenerateResponse {
return &promptkit.GenerateResponse{
Content: `{"summary":"A quiet hour is expected.","forecast_discussion":"Conditions remain settled.","precipitation_timing":""}`,
Usage: promptkit.TokenUsage{PromptTokens: 12, CompletionTokens: 8, TotalTokens: 20},
}
}