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,14 +32,11 @@ returns `413 response_too_large`.
### Request Body ### Request Body
The maintained [request example](../examples/http-run.json) is a complete The maintained [request example](../examples/http-run.json) is a complete
copyable shape. The smallest valid shape is: copyable shape. At the HTTP adapter boundary, the smallest valid shape is:
```json ```json
{ {
"prompt_id": "generic.markdown_summary", "prompt_id": "generic.markdown_summary"
"inputs": {
"transcript": {"type": "inline", "body": "Source text"}
}
} }
``` ```
@@ -48,7 +45,7 @@ copyable shape. The smallest valid shape is:
| `prompt_id` | yes | Non-blank prompt ID. | | `prompt_id` | yes | Non-blank prompt ID. |
| `prompt_version` | no | Prompt version filter. | | `prompt_version` | no | Prompt version filter. |
| `profile_id` | no | Execution-profile ID; otherwise the prompt must set `default_profile`. | | `profile_id` | no | Execution-profile ID; otherwise the prompt must set `default_profile`. |
| `inputs` | yes | Non-empty object mapping input names to references. | | `inputs` | no | Optional object mapping input names to references. Promptkit decides whether the selected definition needs them. |
| `vars` | no | Object mapping template-variable names to strings. | | `vars` | no | Object mapping template-variable names to strings. |
| `model` | no | Runtime model-override object. | | `model` | no | Runtime model-override object. |
| `include_raw_output` | no | Include `raw_model_output` when true. | | `include_raw_output` | no | Include `raw_model_output` when true. |
@@ -83,8 +80,9 @@ empty string explicitly clears it. JSON `null` is treated as omission.
### Strict JSON ### Strict JSON
Request decoding rejects malformed JSON, unknown fields at every request level, Request decoding rejects malformed JSON, unknown fields at every request level,
and trailing JSON tokens with `400 invalid_json`. A blank `prompt_id` or and trailing JSON tokens with `400 invalid_json`. A blank `prompt_id` returns
empty `inputs` object returns `400 invalid_request`. `400 invalid_request`. Omitted or empty `inputs` are passed to Promptkit, which
reports any definition-required or template-referenced inputs.
### Success Response ### Success Response

View File

@@ -39,7 +39,6 @@ Required flags:
| Flag | Meaning | | Flag | Meaning |
| --- | --- | | --- | --- |
| `--prompt <id>` | Prompt ID to execute. | | `--prompt <id>` | Prompt ID to execute. |
| `--input name=path` | Input file mapping; repeat or use comma-separated mappings. |
Optional flags: Optional flags:
@@ -49,7 +48,9 @@ Optional flags:
| `--prompt-dir <dir>` | Prompt-definition directory override. | | `--prompt-dir <dir>` | Prompt-definition directory override. |
| `--profile-dir <dir>` | Custom profile-directory override. | | `--profile-dir <dir>` | Custom profile-directory override. |
| `--schema-dir <dir>` | Schema base-directory override. | | `--schema-dir <dir>` | Schema base-directory override. |
| `--prompt-version <version>` | Optional prompt-definition version selector. |
| `--profile <id>` | Execution-profile override. | | `--profile <id>` | Execution-profile override. |
| `--input name=path` | Optional input file mapping; repeat or use comma-separated mappings. |
| `--var name=value` | Template-variable mapping; repeat or use comma-separated mappings. | | `--var name=value` | Template-variable mapping; repeat or use comma-separated mappings. |
| `--out <path>` | Write generated content to this file instead of stdout. | | `--out <path>` | Write generated content to this file instead of stdout. |
| `--llm-base-url <url>` | Runtime endpoint override. | | `--llm-base-url <url>` | Runtime endpoint override. |
@@ -73,15 +74,20 @@ zero-second override. The timeout layers are defined in the
There is no raw API-key flag. Use `--api-key-env`. There is no raw API-key flag. Use `--api-key-env`.
`--prompt-version` is passed directly to Promptkit. When it is omitted, the
selected prompt ID must have exactly one available version. `--input` is
optional at the CLI boundary: Promptkit decides whether the selected definition
requires declared inputs or template-referenced values.
## `scriptorium render` ## `scriptorium render`
```text ```text
scriptorium render [flags] scriptorium render [flags]
``` ```
`--prompt <id>` and at least one `--input name=path` are required. The `--prompt <id>` is required. The following optional flags are supported:
following optional flags are supported: `--config`, `--prompt-dir`, `--config`, `--prompt-dir`, `--profile-dir`, `--prompt-version`, `--profile`,
`--profile-dir`, `--profile`, `--var`, `--out`, `--llm-base-url`, `--input`, `--var`, `--out`, `--llm-base-url`,
`--model`, `--api-key-env`, `--temperature`, `--max-tokens`, `--top-p`, `--model`, `--api-key-env`, `--temperature`, `--max-tokens`, `--top-p`,
`--timeout`, and `--format text|json`. Their meanings match the corresponding `--timeout`, and `--format text|json`. Their meanings match the corresponding
`run` flags; `--format` selects prepared-run output and otherwise uses `run` flags; `--format` selects prepared-run output and otherwise uses

View File

@@ -29,10 +29,10 @@ described by its tagged
### CLI ### CLI
`run` calls `promptkit.Engine.Run`; `render` calls `run` calls `promptkit.Engine.Run`; `render` calls
`promptkit.Engine.Prepare`. Both share request mapping for prompt/profile `promptkit.Engine.Prepare`. Both share request mapping for prompt ID/version
selection, file inputs, variables, and presence-aware execution overrides. and profile selection, optional file inputs, variables, and presence-aware
Omitted framework settings remain zero values so Promptkit resolves its own execution overrides. Omitted framework settings remain zero values so Promptkit
defaults. resolves its own defaults and definition-required inputs.
`serve` constructs Scriptorium's restricted HTTP artifact reader, injects it `serve` constructs Scriptorium's restricted HTTP artifact reader, injects it
with `promptkit.WithArtifactReader`, passes the engine through the HTTP with `promptkit.WithArtifactReader`, passes the engine through the HTTP

View File

@@ -95,6 +95,8 @@ private Promptkit integration mechanism has been introduced.
## Stage 2: Remove Definition-Compatibility Restrictions In Run And Render ## Stage 2: Remove Definition-Compatibility Restrictions In Run And Render
**Completion: Complete.**
Allow the executable adapters to address every valid directory-backed prompt Allow the executable adapters to address every valid directory-backed prompt
definition without imposing input requirements of their own. definition without imposing input requirements of their own.

View File

@@ -32,21 +32,22 @@ const (
type runConfig struct { type runConfig struct {
configPath string configPath string
promptDir string promptDir string
profileDir string profileDir string
promptID string promptID string
profileID string promptVersion string
inputRaw listFlag profileID string
varRaw listFlag inputRaw listFlag
outputPath string varRaw listFlag
llmBaseURL string outputPath string
apiKeyEnv string llmBaseURL string
model string apiKeyEnv string
temperature float64 model string
maxTokens int temperature float64
topP float64 maxTokens int
schemaDir string topP float64
timeout time.Duration schemaDir string
timeout time.Duration
defaultRenderFormat renderformat.PreparedRunOutputFormat 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.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files")
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile 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.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.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.inputRaw, "input", "input mapping(s): name=path (repeatable, comma-separated)")
fs.Var(&cfg.varRaw, "var", "variable mapping(s): name=value (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) == "" { if strings.TrimSpace(cfg.promptID) == "" {
return errors.New("--prompt is required") 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) cfg.promptDir = filepath.Clean(cfg.promptDir)
if strings.TrimSpace(cfg.profileDir) != "" { if strings.TrimSpace(cfg.profileDir) != "" {
cfg.profileDir = filepath.Clean(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) { func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
inputMappings, err := parseMappings(cfg.inputRaw, false) var inputMappings map[string]string
if err != nil { var err error
return promptkit.RunRequest{}, fmt.Errorf("input parse error: %w", err) 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{} varMappings := map[string]string{}
@@ -560,9 +563,12 @@ func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
} }
} }
inputs := make(map[string]promptkit.ArtifactRef, len(inputMappings)) var inputs map[string]promptkit.ArtifactRef
for name, path := range inputMappings { if len(inputMappings) > 0 {
inputs[name] = promptkit.File(path) inputs = make(map[string]promptkit.ArtifactRef, len(inputMappings))
for name, path := range inputMappings {
inputs[name] = promptkit.File(path)
}
} }
var modelOverride *promptkit.ExecutionTargetOverride var modelOverride *promptkit.ExecutionTargetOverride
@@ -588,11 +594,12 @@ func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) {
} }
return promptkit.RunRequest{ return promptkit.RunRequest{
PromptID: cfg.promptID, PromptID: cfg.promptID,
ProfileID: cfg.profileID, PromptVersion: cfg.promptVersion,
Inputs: inputs, ProfileID: cfg.profileID,
Vars: varMappings, Inputs: inputs,
Execution: modelOverride, Vars: varMappings,
Execution: modelOverride,
}, nil }, nil
} }
@@ -687,7 +694,7 @@ func printSummary(stderr io.Writer, res *promptkit.RunResult) {
func printUsage(w io.Writer) { func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: scriptorium <run|render|serve> ...") 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, " 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 --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, " 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) 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") t.Fatal("expected missing --prompt error")
} }
_, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p"}) cfg, err = parseRunArgs([]string{"--config", configPath, "--prompt-dir", "./prompts", "--profile-dir", "./profiles", "--prompt", "p"})
if err == nil { if err != nil {
t.Fatal("expected missing --input error") 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", "--prompt-dir", "./prompts",
"--profile-dir", "./profiles", "--profile-dir", "./profiles",
"--prompt", "prompt.a", "--prompt", "prompt.a",
"--prompt-version", "2",
"--profile", "profile.a", "--profile", "profile.a",
"--input", "a=b", "--input", "a=b",
"--llm-base-url", "http://x/v1", "--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") { if cfg.promptDir != filepath.Clean("./prompts") || cfg.profileDir != filepath.Clean("./profiles") {
t.Fatalf("unexpected dirs: prompt=%q profile=%q", cfg.promptDir, cfg.profileDir) t.Fatalf("unexpected dirs: prompt=%q profile=%q", cfg.promptDir, cfg.profileDir)
} }
if cfg.promptID != "prompt.a" || cfg.profileID != "profile.a" { if cfg.promptID != "prompt.a" || cfg.promptVersion != "2" || cfg.profileID != "profile.a" {
t.Fatalf("unexpected prompt/profile ids: %q %q", cfg.promptID, cfg.profileID) 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 { if !cfg.llmBaseURLSet || !cfg.modelSet || !cfg.temperatureSet || !cfg.maxTokensSet || !cfg.topPSet || !cfg.timeoutSet || !cfg.apiKeyEnvSet {
t.Fatalf("expected override flags set, got %+v", cfg) 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 var stderr bytes.Buffer
code := Run(nil, io.Discard, &stderr) code := Run(nil, io.Discard, &stderr)
if code != ExitRuntimeError { if code != ExitRuntimeError {
@@ -201,6 +205,7 @@ func TestUsageIncludesServeFileAndSizeLimitFlags(t *testing.T) {
usage := stderr.String() usage := stderr.String()
for _, want := range []string{ for _, want := range []string{
"--prompt-version VERSION",
"--artifact-root", "--artifact-root",
"--max-request-bytes", "--max-request-bytes",
"--max-artifact-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) { func TestParseRunArgsFailsClearlyWhenNoEffectivePromptDir(t *testing.T) {
configPath := writeAppConfigFile(t, ` configPath := writeAppConfigFile(t, `
profile_dir: ./profiles 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) { func TestRunCommandPromptDefaultProfileWorksThroughCLIPath(t *testing.T) {
lib := newCLITestLibrary(t) lib := newCLITestLibrary(t)
inputPath := lib.writeInputFile(t, "transcript.md", "hello") 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\"}}") 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) { func writePromptFileWithTemplate(t *testing.T, dir, id, defaultProfile, templateContent string) {
t.Helper() t.Helper()
data := fmt.Sprintf(`id: %s 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") writeError(w, http.StatusBadRequest, "invalid_request", "prompt_id is required")
return return
} }
if len(req.Inputs) == 0 { var mappedInputs map[string]promptkit.ArtifactRef
writeError(w, http.StatusBadRequest, "invalid_request", "inputs is required") if len(req.Inputs) > 0 {
return mappedInputs = make(map[string]promptkit.ArtifactRef, len(req.Inputs))
} for name, in := range req.Inputs {
mappedInputs[name] = promptkit.ArtifactRef{
mappedInputs := make(map[string]promptkit.ArtifactRef, len(req.Inputs)) Type: promptkit.ArtifactRefType(in.Type),
for name, in := range req.Inputs { URI: in.URI,
mappedInputs[name] = promptkit.ArtifactRef{ Body: in.Body,
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) { func TestHandlerInlineRefsWorkWithoutArtifactRoot(t *testing.T) {
h := newArtifactRootHandler(t, "") h := newArtifactRootHandler(t, "")
@@ -931,6 +1049,31 @@ model: model
return engine 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) { func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
t.Helper() t.Helper()