Files
scriptorium/engine_test.go

1695 lines
55 KiB
Go

package scriptorium_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/scriptorium"
)
func TestNewEngineRejectsMissingPromptDir(t *testing.T) {
_, err := scriptorium.NewEngine(scriptorium.Config{ProfileDir: "./examples/profiles"})
if !errors.Is(err, scriptorium.ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
}
func TestNewEngineAcceptsMissingProfileDir(t *testing.T) {
_, err := scriptorium.NewEngine(scriptorium.Config{PromptDir: "./examples/prompts"})
if err != nil {
t.Fatalf("expected missing profile dir to use built-ins, got %v", err)
}
}
func TestPrepareWorksWithExampleDirectoriesAndFileInputs(t *testing.T) {
engine := newExampleEngine(t)
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.File("./examples/fixtures/transcript.md"),
"glossary": scriptorium.File("./examples/fixtures/glossary.yml"),
},
})
if err != nil {
t.Fatalf("expected prepare to succeed, got %v", err)
}
if prepared.PromptID != "generic.markdown_summary" {
t.Fatalf("unexpected prompt id: %q", prepared.PromptID)
}
if prepared.SelectedProfileID != "local-fast" {
t.Fatalf("unexpected selected profile: %q", prepared.SelectedProfileID)
}
if prepared.EffectiveModelParams.Model != "gpt-4o-mini" {
t.Fatalf("unexpected effective model: %q", prepared.EffectiveModelParams.Model)
}
if len(prepared.Messages) != 2 {
t.Fatalf("expected rendered messages, got %d", len(prepared.Messages))
}
if prepared.InputHashes["transcript"] == "" || prepared.InputHashes["glossary"] == "" {
t.Fatalf("expected input hashes, got %#v", prepared.InputHashes)
}
}
func TestPrepareWorksWithInlineInputs(t *testing.T) {
engine := newExampleEngine(t)
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin scouts the tower.\nKara lights a lantern."),
"glossary": scriptorium.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 = "SCRIPTORIUM_API_KEY"
const secret = "public-api-test-secret"
t.Setenv(envName, secret)
engine := newExampleEngine(t)
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.structured_events",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.File("./examples/fixtures/transcript.md"),
"glossary": scriptorium.File("./examples/fixtures/glossary.yml"),
},
})
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 := scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "local-fast",
APIKey: secret,
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.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 := scriptorium.GenerateRequest{
Prompt: scriptorium.RenderedPrompt{Messages: []scriptorium.RenderedMessage{
{Role: "user", Content: "secret prompt content"},
}},
Target: scriptorium.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 TestPreparePreservesExplicitZeroExecutionOverrides(t *testing.T) {
engine := newExampleEngine(t)
zeroFloat := 0.0
zeroInt := 0
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.File("./examples/fixtures/transcript.md"),
"glossary": scriptorium.File("./examples/fixtures/glossary.yml"),
},
Execution: &scriptorium.ExecutionTargetOverride{
Temperature: &zeroFloat,
MaxTokens: &zeroInt,
TopP: &zeroFloat,
TimeoutSeconds: &zeroInt,
},
})
if err != nil {
t.Fatalf("expected prepare to succeed, got %v", err)
}
target := prepared.EffectiveModelParams
if target.Temperature != 0 || target.MaxTokens != 0 || target.TopP != 0 || target.TimeoutSeconds != 0 {
t.Fatalf("expected explicit zero overrides in effective target, got %+v", target)
}
}
func TestRunSucceedsWithInjectedLLMClient(t *testing.T) {
const envName = "SCRIPTORIUM_API_KEY"
const secret = "run-secret-value"
t.Setenv(envName, secret)
fake := &fakeLLMClient{
response: &scriptorium.GenerateResponse{
Content: "# Summary\n\nDone.",
Usage: scriptorium.TokenUsage{
PromptTokens: 10,
CompletionTokens: 5,
TotalTokens: 15,
CachedTokens: 3,
CacheWriteTokens: 2,
},
},
}
engine := newExampleEngineWithOptions(t, "./examples/schemas", scriptorium.WithLLMClient(fake))
result, err := engine.Run(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
Execution: &scriptorium.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 != scriptorium.ValidationPassed || !result.Validation.IsValid {
t.Fatalf("expected passed validation, got %+v", result.Validation)
}
if result.PromptID != "generic.markdown_summary" || result.SelectedProfileID != "local-fast" || result.ModelName != "gpt-4o-mini" {
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 TestRunPassesPreparedRequestToInjectedLLMClient(t *testing.T) {
const directKey = "direct-injected-key"
fake := &fakeLLMClient{
response: &scriptorium.GenerateResponse{Content: "ok"},
}
engine := newExampleEngineWithOptions(t, "./examples/schemas", scriptorium.WithLLMClient(fake))
zeroFloat := 0.0
zeroInt := 0
_, err := engine.Run(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
APIKey: directKey,
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
Execution: &scriptorium.ExecutionTargetOverride{
Temperature: &zeroFloat,
MaxTokens: &zeroInt,
TopP: &zeroFloat,
TimeoutSeconds: &zeroInt,
},
})
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 req.Target.Model != "gpt-4o-mini" || req.Target.Temperature != 0 || req.Target.MaxTokens != 0 || req.Target.TopP != 0 || req.Target.TimeoutSeconds != 0 {
t.Fatalf("unexpected effective target: %+v", req.Target)
}
if !req.TargetPresence.Temperature || !req.TargetPresence.MaxTokens || !req.TargetPresence.TopP || !req.TargetPresence.TimeoutSeconds {
t.Fatalf("expected explicit zero target presence, got %+v", req.TargetPresence)
}
if req.StructuredOutput != nil {
t.Fatalf("did not expect structured output for markdown prompt: %+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 TestRunUsesDirectAPIKeyWithDefaultLLMClient(t *testing.T) {
const directKey = "direct-public-key"
const missingEnv = "SCRIPTORIUM_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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: profileDir,
SchemaDir: "./examples/schemas",
})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
result, err := engine.Run(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "direct-auth",
APIKey: directKey,
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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 TestPrepareDirectAPIKeyBypassesMissingEnvWithoutLeakingOrHashing(t *testing.T) {
const missingEnv = "SCRIPTORIUM_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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: profileDir,
SchemaDir: "./examples/schemas",
})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
baseReq := scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "direct-prepare",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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 = "SCRIPTORIUM_PUBLIC_AUTH_MISSING"
t.Setenv(missingEnv, "")
profileDir := t.TempDir()
writePublicProfileFileWithAPIKeyEnv(t, profileDir, "requires-auth", "http://localhost:8000/v1", "test-model", missingEnv)
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: profileDir,
SchemaDir: "./examples/schemas",
})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
_, err = engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "requires-auth",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
})
if !errors.Is(err, scriptorium.ErrInvalidRequest) {
t.Fatalf("expected invalid request for missing credentials, got %v", err)
}
if err == nil || !strings.Contains(err.Error(), missingEnv) {
t.Fatalf("expected missing env name in error, got %v", err)
}
}
func TestRunValidationFailureReturnsResult(t *testing.T) {
fake := &fakeLLMClient{
response: &scriptorium.GenerateResponse{Content: ""},
}
engine := newExampleEngineWithOptions(t, "./examples/schemas", scriptorium.WithLLMClient(fake))
result, err := engine.Run(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
})
if err != nil {
t.Fatalf("expected validation failure as successful result, got %v", err)
}
if result.Validation.Status != scriptorium.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 scriptorium.RunRequest
client scriptorium.LLMClient
schemaDir string
want error
}{
{
name: "invalid request",
req: scriptorium.RunRequest{},
client: &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: "ok"}},
want: scriptorium.ErrInvalidRequest,
},
{
name: "prompt not found",
req: scriptorium.RunRequest{PromptID: "missing.prompt"},
client: &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: "ok"}},
want: scriptorium.ErrPromptNotFound,
},
{
name: "profile not found",
req: scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "missing-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
},
},
client: &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: "ok"}},
want: scriptorium.ErrProfileNotFound,
},
{
name: "artifact load",
req: scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.File("./examples/fixtures/does-not-exist.md"),
},
},
client: &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: "ok"}},
want: scriptorium.ErrArtifactLoad,
},
{
name: "prompt render",
req: scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
},
client: &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: "ok"}},
want: scriptorium.ErrPromptRender,
},
{
name: "llm failure",
req: scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
},
client: &fakeLLMClient{err: llmErr},
want: scriptorium.ErrLLMGenerate,
},
{
name: "validation runtime failure",
req: scriptorium.RunRequest{
PromptID: "generic.structured_events",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
},
},
client: &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: `{"events":[]}`}},
schemaDir: t.TempDir(),
want: scriptorium.ErrValidation,
},
}
t.Setenv("SCRIPTORIUM_API_KEY", "test-secret")
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
schemaDir := tc.schemaDir
if schemaDir == "" {
schemaDir = "./examples/schemas"
}
engine := newExampleEngineWithOptions(t, schemaDir, scriptorium.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)
}
})
}
}
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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: profileDir,
SchemaDir: "./examples/schemas",
})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
_, err = engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "raw-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
})
if !errors.Is(err, scriptorium.ErrProfileLoad) {
t.Fatalf("expected ErrProfileLoad, got %v", err)
}
if errors.Is(err, scriptorium.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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: profileDir,
SchemaDir: "./examples/schemas",
})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
_, err = engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "broken-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
})
if !errors.Is(err, scriptorium.ErrProfileLoad) {
t.Fatalf("expected ErrProfileLoad, got %v", err)
}
if errors.Is(err, scriptorium.ErrPromptLoad) {
t.Fatalf("did not expect ErrPromptLoad, got %v", err)
}
}
func TestPromptRepositoryReadFailureMapsToPromptLoad(t *testing.T) {
missingPromptDir := filepath.Join(t.TempDir(), "missing-prompts")
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: missingPromptDir,
ProfileDir: "./examples/profiles",
SchemaDir: "./examples/schemas",
})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
_, err = engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
})
if !errors.Is(err, scriptorium.ErrPromptLoad) {
t.Fatalf("expected ErrPromptLoad, got %v", err)
}
if errors.Is(err, scriptorium.ErrProfileLoad) {
t.Fatalf("did not expect ErrProfileLoad, got %v", err)
}
}
func TestSelectedProfileRepositoryReadFailureMapsToProfileLoad(t *testing.T) {
missingProfileDir := filepath.Join(t.TempDir(), "missing-profiles")
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: missingProfileDir,
SchemaDir: "./examples/schemas",
})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
_, err = engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "local-fast",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
})
if !errors.Is(err, scriptorium.ErrProfileLoad) {
t.Fatalf("expected ErrProfileLoad, got %v", err)
}
if errors.Is(err, scriptorium.ErrPromptLoad) {
t.Fatalf("did not expect ErrPromptLoad, got %v", err)
}
}
func TestPrepareUsesBuiltInProfileWithoutProfileDir(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "test-key")
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "mistral-small-3",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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.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 := scriptorium.NewEngine(scriptorium.Config{PromptDir: promptDir})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "prompt.builtin.default",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: profileDir,
SchemaDir: "./examples/schemas",
})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "mistral-small-3",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: profileDir,
SchemaDir: "./examples/schemas",
})
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
_, err = engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "mistral-small-3",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
})
if !errors.Is(err, scriptorium.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: local-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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: t.TempDir(),
ProfileDir: "./examples/profiles",
SchemaDir: "./examples/schemas",
}, scriptorium.WithPromptFS(promptFS, "assets/prompts"))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "fs.summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.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 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: local-fast
inputs:
- name: transcript
required: true
messages:
- role: user
content: "Summarize {{input \"transcript\"}} from file."
output:
format: text
validation_mode: none
repair_attempts: 0
`), 0o644); err != nil {
t.Fatal(err)
}
engine, err := scriptorium.NewEngine(scriptorium.Config{
ProfileDir: "./examples/profiles",
SchemaDir: "./examples/schemas",
}, scriptorium.WithPromptFile(promptPath))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "single.file.prompt",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.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)
}
}
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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
}, scriptorium.WithProfileFS(profileFS, "profiles"))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "mistral-small-3",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
}, scriptorium.WithProfileFile(profilePath))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "mistral-small-3",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
}, scriptorium.WithProfiles(scriptorium.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(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "memory-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
},
scriptorium.WithProfileFS(profileFS, "profiles"),
scriptorium.WithProfiles(scriptorium.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(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "mistral-small-3",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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 := scriptorium.NewEngine(scriptorium.Config{PromptDir: "./examples/prompts"},
scriptorium.WithProfiles(
scriptorium.Profile{ID: "duplicate", Endpoint: "http://one/v1", Model: "one"},
scriptorium.Profile{ID: "duplicate", Endpoint: "http://two/v1", Model: "two"},
),
)
if !errors.Is(err, scriptorium.ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
}
func TestOpenAICompatibleProfileRunsThroughNormalProfilePath(t *testing.T) {
fake := &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: "ok"}}
prof := scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
ID: "template-profile",
Endpoint: "http://template/v1",
Model: "template-model",
APIKeyRequired: true,
ExtraParams: map[string]any{
"provider": "template",
},
})
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
}, scriptorium.WithProfiles(prof), scriptorium.WithLLMClient(fake))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
_, err = engine.Run(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "template-profile",
APIKey: "template-key",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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.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 TestInMemoryProfileAPIKeyRequiredBehavior(t *testing.T) {
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
}, scriptorium.WithProfiles(scriptorium.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 := scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "requires-key",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
}
_, err = engine.Prepare(context.Background(), req)
if !errors.Is(err, scriptorium.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 := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
}, scriptorium.WithProfiles(scriptorium.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(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "no-key-required",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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: &scriptorium.GenerateResponse{Content: "ok"}}
labels := map[string]string{"route": "primary"}
ids := []int{1, 2, 3}
extraParams := map[string]any{
"labels": labels,
"ids": ids,
}
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
},
scriptorium.WithProfiles(scriptorium.Profile{
ID: "copy-profile",
Endpoint: "http://copy/v1",
Model: "copy-model",
ExtraParams: extraParams,
}),
scriptorium.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(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "copy-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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 := scriptorium.NewEngine(scriptorium.Config{PromptDir: "./examples/prompts"},
scriptorium.WithProfiles(scriptorium.Profile{
ID: "invalid-extra-params",
Endpoint: "http://invalid/v1",
Model: "invalid-model",
ExtraParams: tc.extraParams,
}),
)
if !errors.Is(err, scriptorium.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 := scriptorium.NewEngine(scriptorium.Config{PromptDir: "./examples/prompts"},
scriptorium.WithProfiles(scriptorium.Profile{
ID: "cyclic-extra-params",
Endpoint: "http://cyclic/v1",
Model: "cyclic-model",
ExtraParams: tc.extraParams,
}),
)
if !errors.Is(err, scriptorium.ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
})
}
}
func TestRunStructuredOutputWorksWithSchemaFS(t *testing.T) {
fake := &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: `{"events":[]}`}}
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: t.TempDir(),
ProfileDir: "./examples/profiles",
SchemaDir: t.TempDir(),
},
scriptorium.WithPromptFS(publicStructuredPromptFS("schema.fs.prompt", "events.schema.json"), "prompts"),
scriptorium.WithSchemaFS(publicSchemaFS(), "schemas"),
scriptorium.WithLLMClient(fake),
)
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
result, err := engine.Run(context.Background(), scriptorium.RunRequest{
PromptID: "schema.fs.prompt",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
},
})
if err != nil {
t.Fatalf("expected run to succeed, got %v", err)
}
if result.Validation.Status != scriptorium.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: &scriptorium.GenerateResponse{Content: `{"events":[]}`}}
engine, err := scriptorium.NewEngine(scriptorium.Config{
ProfileDir: "./examples/profiles",
},
scriptorium.WithPromptFS(publicStructuredPromptFS("schema.file.prompt", "events.schema.json"), "prompts"),
scriptorium.WithSchemaFile(schemaPath),
scriptorium.WithLLMClient(fake),
)
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
result, err := engine.Run(context.Background(), scriptorium.RunRequest{
PromptID: "schema.file.prompt",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
},
})
if err != nil {
t.Fatalf("expected run to succeed, got %v", err)
}
if result.Validation.Status != scriptorium.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 scriptorium.Option
}{
{name: "prompt fs nil", opt: scriptorium.WithPromptFS(nil, "prompts")},
{name: "prompt fs empty root", opt: scriptorium.WithPromptFS(fstest.MapFS{}, "")},
{name: "prompt file empty", opt: scriptorium.WithPromptFile("")},
{name: "prompt file missing", opt: scriptorium.WithPromptFile(missingFile)},
{name: "prompt file directory", opt: scriptorium.WithPromptFile(directoryPath)},
{name: "profile fs nil", opt: scriptorium.WithProfileFS(nil, "profiles")},
{name: "profile fs empty root", opt: scriptorium.WithProfileFS(fstest.MapFS{}, "")},
{name: "profile file empty", opt: scriptorium.WithProfileFile("")},
{name: "schema fs nil", opt: scriptorium.WithSchemaFS(nil, "schemas")},
{name: "schema fs empty root", opt: scriptorium.WithSchemaFS(fstest.MapFS{}, "")},
{name: "schema file empty", opt: scriptorium.WithSchemaFile("")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := scriptorium.NewEngine(scriptorium.Config{PromptDir: "./examples/prompts"}, tc.opt)
if !errors.Is(err, scriptorium.ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
})
}
}
func TestPackageOptionsComposeFromSlice(t *testing.T) {
fake := &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: "ok"}}
options := []scriptorium.Option{
nil,
scriptorium.WithProfiles(scriptorium.Profile{
ID: "slice-profile",
Endpoint: "http://slice/v1",
Model: "slice-model",
}),
scriptorium.WithLLMClient(fake),
}
engine, err := scriptorium.NewEngine(scriptorium.Config{
PromptDir: "./examples/prompts",
SchemaDir: "./examples/schemas",
}, options...)
if err != nil {
t.Fatalf("expected package-provided options to compose, got %v", err)
}
_, err = engine.Run(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
ProfileID: "slice-profile",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.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: &scriptorium.GenerateResponse{Content: "ok"}}
engine := newExampleEngineWithOptions(t, "./examples/schemas", scriptorium.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(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
Execution: &scriptorium.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) {
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) {
fake := &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: "ok"}}
engine := newExampleEngineWithOptions(t, "./examples/schemas", scriptorium.WithLLMClient(fake))
_, err := engine.Run(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
Execution: &scriptorium.ExecutionTargetOverride{ExtraParams: tc.extraParams},
})
if !errors.Is(err, scriptorium.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 TestRunRejectsCyclicExtraParams(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) {
fake := &fakeLLMClient{response: &scriptorium.GenerateResponse{Content: "ok"}}
engine := newExampleEngineWithOptions(t, "./examples/schemas", scriptorium.WithLLMClient(fake))
_, err := engine.Run(context.Background(), scriptorium.RunRequest{
PromptID: "generic.markdown_summary",
Inputs: map[string]scriptorium.ArtifactRef{
"transcript": scriptorium.Inline("Rin opens the gate."),
"glossary": scriptorium.Inline("gate: A guarded passage."),
},
Execution: &scriptorium.ExecutionTargetOverride{ExtraParams: tc.extraParams},
})
if !errors.Is(err, scriptorium.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 := scriptorium.NewEngine(exampleConfig("./examples/schemas"), scriptorium.WithLLMClient(nil))
if !errors.Is(err, scriptorium.ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
}
func TestNewEngineConstructsDefaultLLMClientWithoutCredentials(t *testing.T) {
if _, err := scriptorium.NewEngine(exampleConfig("./examples/schemas")); err != nil {
t.Fatalf("expected default engine construction without credentials to succeed, got %v", err)
}
}
func newExampleEngine(t *testing.T) *scriptorium.Engine {
t.Helper()
for _, path := range []string{
"./examples/prompts",
"./examples/profiles",
"./examples/schemas",
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected example path %s to exist: %v", path, err)
}
}
engine, err := scriptorium.NewEngine(exampleConfig("./examples/schemas"))
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
return engine
}
func newExampleEngineWithOptions(t *testing.T, schemaDir string, opts ...scriptorium.Option) *scriptorium.Engine {
t.Helper()
engine, err := scriptorium.NewEngine(exampleConfig(schemaDir), opts...)
if err != nil {
t.Fatalf("expected engine construction to succeed, got %v", err)
}
return engine
}
func exampleConfig(schemaDir string) scriptorium.Config {
return scriptorium.Config{
PromptDir: "./examples/prompts",
ProfileDir: "./examples/profiles",
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: local-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 fakeLLMClient struct {
response *scriptorium.GenerateResponse
err error
requests []scriptorium.GenerateRequest
}
func (f *fakeLLMClient) Generate(_ context.Context, req scriptorium.GenerateRequest) (*scriptorium.GenerateResponse, error) {
f.requests = append(f.requests, req)
if f.err != nil {
return nil, f.err
}
return f.response, nil
}