Allow prompt version selection without inputs

This commit is contained in:
2026-08-29 14:12:51 +00:00
parent 1806df9888
commit 5f946a5a1f
8 changed files with 344 additions and 64 deletions

View File

@@ -32,21 +32,22 @@ const (
type runConfig struct {
configPath string
promptDir string
profileDir string
promptID string
profileID string
inputRaw listFlag
varRaw listFlag
outputPath string
llmBaseURL string
apiKeyEnv string
model string
temperature float64
maxTokens int
topP float64
schemaDir string
timeout time.Duration
promptDir string
profileDir string
promptID string
promptVersion string
profileID string
inputRaw listFlag
varRaw listFlag
outputPath string
llmBaseURL string
apiKeyEnv string
model string
temperature float64
maxTokens int
topP float64
schemaDir string
timeout time.Duration
defaultRenderFormat renderformat.PreparedRunOutputFormat
@@ -349,6 +350,7 @@ func registerExecutionRequestFlags(fs *flag.FlagSet, cfg *runConfig) {
fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files")
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files")
fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to run")
fs.StringVar(&cfg.promptVersion, "prompt-version", "", "optional prompt definition version")
fs.StringVar(&cfg.profileID, "profile", "", "optional execution profile ID; if omitted, prompt default_profile is used")
fs.Var(&cfg.inputRaw, "input", "input mapping(s): name=path (repeatable, comma-separated)")
fs.Var(&cfg.varRaw, "var", "variable mapping(s): name=value (repeatable, comma-separated)")
@@ -389,9 +391,6 @@ func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error {
if strings.TrimSpace(cfg.promptID) == "" {
return errors.New("--prompt is required")
}
if len(cfg.inputRaw) == 0 {
return errors.New("at least one --input is required")
}
cfg.promptDir = filepath.Clean(cfg.promptDir)
if strings.TrimSpace(cfg.profileDir) != "" {
cfg.profileDir = filepath.Clean(cfg.profileDir)
@@ -547,9 +546,13 @@ func newEngine(cfg *runConfig, options ...promptkit.Option) (*promptkit.Engine,
}
func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
inputMappings, err := parseMappings(cfg.inputRaw, false)
if err != nil {
return promptkit.RunRequest{}, fmt.Errorf("input parse error: %w", err)
var inputMappings map[string]string
var err error
if len(cfg.inputRaw) > 0 {
inputMappings, err = parseMappings(cfg.inputRaw, false)
if err != nil {
return promptkit.RunRequest{}, fmt.Errorf("input parse error: %w", err)
}
}
varMappings := map[string]string{}
@@ -560,9 +563,12 @@ func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
}
}
inputs := make(map[string]promptkit.ArtifactRef, len(inputMappings))
for name, path := range inputMappings {
inputs[name] = promptkit.File(path)
var inputs map[string]promptkit.ArtifactRef
if len(inputMappings) > 0 {
inputs = make(map[string]promptkit.ArtifactRef, len(inputMappings))
for name, path := range inputMappings {
inputs[name] = promptkit.File(path)
}
}
var modelOverride *promptkit.ExecutionTargetOverride
@@ -588,11 +594,12 @@ func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
}
return promptkit.RunRequest{
PromptID: cfg.promptID,
ProfileID: cfg.profileID,
Inputs: inputs,
Vars: varMappings,
Execution: modelOverride,
PromptID: cfg.promptID,
PromptVersion: cfg.promptVersion,
ProfileID: cfg.profileID,
Inputs: inputs,
Vars: varMappings,
Execution: modelOverride,
}, nil
}
@@ -687,7 +694,7 @@ func printSummary(stderr io.Writer, res *promptkit.RunResult) {
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: scriptorium <run|render|serve> ...")
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID --input name=path [--input ...] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--out path] [--timeout 10m]")
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID --input name=path [--input ...] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--format text|json] [--out path] [--timeout 10m]")
fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--out path] [--timeout 10m]")
fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--var k=v] [--format text|json] [--out path] [--timeout 10m]")
fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault)
}

View File

@@ -87,9 +87,12 @@ func TestParseRunArgsRequiredFlags(t *testing.T) {
t.Fatal("expected missing --prompt error")
}
_, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p"})
if err == nil {
t.Fatal("expected missing --input error")
cfg, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p"})
if err != nil {
t.Fatalf("expected omitted --input to be accepted, got %v", err)
}
if len(cfg.inputRaw) != 0 {
t.Fatalf("expected no input mappings, got %#v", cfg.inputRaw)
}
}
@@ -98,6 +101,7 @@ func TestParseRunArgsFlagMapping(t *testing.T) {
"--prompt-dir", "./prompts",
"--profile-dir", "./profiles",
"--prompt", "prompt.a",
"--prompt-version", "2",
"--profile", "profile.a",
"--input", "a=b",
"--llm-base-url", "http://x/v1",
@@ -114,8 +118,8 @@ func TestParseRunArgsFlagMapping(t *testing.T) {
if cfg.promptDir != filepath.Clean("./prompts") || cfg.profileDir != filepath.Clean("./profiles") {
t.Fatalf("unexpected dirs: prompt=%q profile=%q", cfg.promptDir, cfg.profileDir)
}
if cfg.promptID != "prompt.a" || cfg.profileID != "profile.a" {
t.Fatalf("unexpected prompt/profile ids: %q %q", cfg.promptID, cfg.profileID)
if cfg.promptID != "prompt.a" || cfg.promptVersion != "2" || cfg.profileID != "profile.a" {
t.Fatalf("unexpected prompt/version/profile ids: %q %q %q", cfg.promptID, cfg.promptVersion, cfg.profileID)
}
if !cfg.llmBaseURLSet || !cfg.modelSet || !cfg.temperatureSet || !cfg.maxTokensSet || !cfg.topPSet || !cfg.timeoutSet || !cfg.apiKeyEnvSet {
t.Fatalf("expected override flags set, got %+v", cfg)
@@ -192,7 +196,7 @@ func TestParseServeArgsRejectsRuntimeOverrideFlags(t *testing.T) {
}
}
func TestUsageIncludesServeFileAndSizeLimitFlags(t *testing.T) {
func TestUsageIncludesExecutionAndServeFlags(t *testing.T) {
var stderr bytes.Buffer
code := Run(nil, io.Discard, &stderr)
if code != ExitRuntimeError {
@@ -201,6 +205,7 @@ func TestUsageIncludesServeFileAndSizeLimitFlags(t *testing.T) {
usage := stderr.String()
for _, want := range []string{
"--prompt-version VERSION",
"--artifact-root",
"--max-request-bytes",
"--max-artifact-bytes",
@@ -689,6 +694,22 @@ func TestBuildRunRequestPreservesNumericOverridePresence(t *testing.T) {
}
}
func TestBuildRunRequestAllowsOmittedInputsAndMapsPromptVersion(t *testing.T) {
req, err := buildRunRequestFromConfig(&runConfig{
promptID: "prompt-1",
promptVersion: "2",
})
if err != nil {
t.Fatalf("expected request without inputs to build, got %v", err)
}
if req.PromptVersion != "2" {
t.Fatalf("expected prompt version to be mapped, got %q", req.PromptVersion)
}
if req.Inputs != nil {
t.Fatalf("expected omitted inputs to remain nil, got %#v", req.Inputs)
}
}
func TestParseRunArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) {
configPath := writeAppConfigFile(t, `
profile_dir: ./profiles
@@ -1122,6 +1143,104 @@ func TestRenderCommandExplicitProfileOverridesPromptDefault(t *testing.T) {
}
}
func TestRenderCommandUsesDefinitionInputRulesAndPromptVersions(t *testing.T) {
lib := newCLITestLibrary(t)
writeProfileFile(t, lib.profileDir, "local", "http://127.0.0.1:1/v1", "model")
writePromptDefinition(t, lib.promptDir, "sole.yaml", `id: sole
version: "1"
default_profile: local
messages:
- role: user
content: "hello"
output:
format: text
validation_mode: none
`)
writePromptDefinition(t, lib.promptDir, "versioned-one.yaml", `id: versioned
version: "1"
default_profile: local
messages:
- role: user
content: "one"
output:
format: text
validation_mode: none
`)
writePromptDefinition(t, lib.promptDir, "versioned-two.yaml", `id: versioned
version: "2"
default_profile: local
messages:
- role: user
content: "two"
output:
format: text
validation_mode: none
`)
writePromptDefinition(t, lib.promptDir, "optional.yaml", `id: optional
version: "1"
default_profile: local
inputs:
- name: note
required: false
messages:
- role: user
content: "hello"
output:
format: text
validation_mode: none
`)
writePromptDefinition(t, lib.promptDir, "required.yaml", `id: required
version: "1"
default_profile: local
inputs:
- name: note
required: true
messages:
- role: user
content: "hello"
output:
format: text
validation_mode: none
`)
writePromptDefinition(t, lib.promptDir, "template.yaml", `id: template
version: "1"
default_profile: local
messages:
- role: user
content: '{{input "note"}}'
output:
format: text
validation_mode: none
`)
baseArgs := []string{"--prompt-dir", lib.promptDir, "--profile-dir", lib.profileDir}
for _, tc := range []struct {
name string
args []string
wantCode int
wantText string
}{
{name: "sole version selected when omitted", args: []string{"--prompt", "sole"}, wantCode: ExitOK, wantText: "prompt_version: 1"},
{name: "explicit version selected", args: []string{"--prompt", "versioned", "--prompt-version", "2"}, wantCode: ExitOK, wantText: "prompt_version: 2"},
{name: "multiple versions require selection", args: []string{"--prompt", "versioned"}, wantCode: ExitRuntimeError, wantText: "duplicate prompt definition id"},
{name: "no declared inputs", args: []string{"--prompt", "sole"}, wantCode: ExitOK, wantText: "prompt: sole"},
{name: "optional input omitted", args: []string{"--prompt", "optional"}, wantCode: ExitOK, wantText: "prompt: optional"},
{name: "required input omitted", args: []string{"--prompt", "required"}, wantCode: ExitRuntimeError, wantText: "required"},
{name: "template input omitted", args: []string{"--prompt", "template"}, wantCode: ExitRuntimeError, wantText: "note"},
} {
t.Run(tc.name, func(t *testing.T) {
code, stdout, stderr := runCLICommand(t, renderCommand, append(append([]string{}, baseArgs...), tc.args...))
if code != tc.wantCode {
t.Fatalf("expected exit %d, got %d stderr=%q", tc.wantCode, code, stderr)
}
if !strings.Contains(stdout+stderr, tc.wantText) {
t.Fatalf("expected output to contain %q, stdout=%q stderr=%q", tc.wantText, stdout, stderr)
}
})
}
}
func TestRunCommandPromptDefaultProfileWorksThroughCLIPath(t *testing.T) {
lib := newCLITestLibrary(t)
inputPath := lib.writeInputFile(t, "transcript.md", "hello")
@@ -1340,6 +1459,13 @@ func writePromptFile(t *testing.T, dir, id, defaultProfile string) {
writePromptFileWithTemplate(t, dir, id, defaultProfile, "Summarize: {{input \"transcript\"}}")
}
func writePromptDefinition(t *testing.T, dir, name, definition string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(definition), 0o644); err != nil {
t.Fatalf("write prompt definition: %v", err)
}
}
func writePromptFileWithTemplate(t *testing.T, dir, id, defaultProfile, templateContent string) {
t.Helper()
data := fmt.Sprintf(`id: %s

View File

@@ -76,17 +76,15 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_request", "prompt_id is required")
return
}
if len(req.Inputs) == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "inputs is required")
return
}
mappedInputs := make(map[string]promptkit.ArtifactRef, len(req.Inputs))
for name, in := range req.Inputs {
mappedInputs[name] = promptkit.ArtifactRef{
Type: promptkit.ArtifactRefType(in.Type),
URI: in.URI,
Body: in.Body,
var mappedInputs map[string]promptkit.ArtifactRef
if len(req.Inputs) > 0 {
mappedInputs = make(map[string]promptkit.ArtifactRef, len(req.Inputs))
for name, in := range req.Inputs {
mappedInputs[name] = promptkit.ArtifactRef{
Type: promptkit.ArtifactRefType(in.Type),
URI: in.URI,
Body: in.Body,
}
}
}

View File

@@ -192,6 +192,124 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
}
}
func TestHandlerAllowsOmittedInputsAndMapsPromptVersion(t *testing.T) {
r := &fakeRunner{result: &promptkit.RunResult{
Artifact: promptkit.Artifact{Body: []byte("ok")},
Validation: promptkit.ValidationResult{Status: promptkit.ValidationPassed, Mode: promptkit.ValidationNone, IsValid: true},
EffectiveModelParams: promptkit.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
}}
h := NewHandler(r)
for _, body := range []string{
`{"prompt_id":"prompt-1","prompt_version":"2"}`,
`{"prompt_id":"prompt-1","prompt_version":"2","inputs":{}}`,
} {
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(body))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
if r.last.PromptVersion != "2" {
t.Fatalf("expected prompt version to be mapped, got %q", r.last.PromptVersion)
}
if r.last.Inputs != nil {
t.Fatalf("expected omitted or empty inputs to remain nil, got %#v", r.last.Inputs)
}
}
}
func TestHandlerDelegatesDefinitionInputRequirements(t *testing.T) {
tests := []struct {
name string
definition string
wantStatus int
wantCode string
}{
{
name: "no declared inputs",
definition: `id: p
version: "1"
default_profile: exec
messages:
- role: user
content: "hello"
output:
format: text
validation_mode: none
`,
wantStatus: http.StatusOK,
},
{
name: "optional input omitted",
definition: `id: p
version: "1"
default_profile: exec
inputs:
- name: note
required: false
messages:
- role: user
content: "hello"
output:
format: text
validation_mode: none
`,
wantStatus: http.StatusOK,
},
{
name: "required input omitted",
definition: `id: p
version: "1"
default_profile: exec
inputs:
- name: note
required: true
messages:
- role: user
content: "hello"
output:
format: text
validation_mode: none
`,
wantStatus: http.StatusBadRequest,
wantCode: "prompt_render_failed",
},
{
name: "template input omitted",
definition: `id: p
version: "1"
default_profile: exec
messages:
- role: user
content: '{{input "note"}}'
output:
format: text
validation_mode: none
`,
wantStatus: http.StatusBadRequest,
wantCode: "prompt_render_failed",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
h := newDefinitionHandler(t, tc.definition)
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p"}`))
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != tc.wantStatus {
t.Fatalf("expected %d, got %d body=%s", tc.wantStatus, w.Code, w.Body.String())
}
if tc.wantCode != "" {
assertHTTPErrorCode(t, w, tc.wantStatus, tc.wantCode)
}
})
}
}
func TestHandlerInlineRefsWorkWithoutArtifactRoot(t *testing.T) {
h := newArtifactRootHandler(t, "")
@@ -931,6 +1049,31 @@ model: model
return engine
}
func newDefinitionHandler(t *testing.T, definition string) *Handler {
t.Helper()
promptDir := t.TempDir()
profileDir := t.TempDir()
if err := os.WriteFile(filepath.Join(promptDir, "prompt.yaml"), []byte(definition), 0o644); err != nil {
t.Fatalf("write prompt fixture: %v", err)
}
if err := os.WriteFile(filepath.Join(profileDir, "profile.yaml"), []byte(`id: exec
endpoint: http://example.invalid/v1
model: model
`), 0o644); err != nil {
t.Fatalf("write profile fixture: %v", err)
}
engine, err := promptkit.NewEngine(promptkit.Config{
PromptDir: promptDir,
ProfileDir: profileDir,
}, promptkit.WithLLMClient(handlerLLMClient{}))
if err != nil {
t.Fatalf("construct public engine: %v", err)
}
return NewHandler(engine)
}
func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
t.Helper()