From 5f946a5a1f818b16a1a6ebd6585cbc28e6ab8e30 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 29 Aug 2026 14:12:51 +0000 Subject: [PATCH] Allow prompt version selection without inputs --- docs/api.md | 14 ++- docs/cli.md | 14 ++- docs/internal/adapters.md | 8 +- docs/roadmap/implementation.md | 2 + internal/adapter/cli/run.go | 69 +++++++------ internal/adapter/cli/run_test.go | 138 +++++++++++++++++++++++-- internal/adapter/http/handler.go | 20 ++-- internal/adapter/http/handler_test.go | 143 ++++++++++++++++++++++++++ 8 files changed, 344 insertions(+), 64 deletions(-) diff --git a/docs/api.md b/docs/api.md index 799b4f6..0d76dbe 100644 --- a/docs/api.md +++ b/docs/api.md @@ -32,14 +32,11 @@ returns `413 response_too_large`. ### Request Body 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 { - "prompt_id": "generic.markdown_summary", - "inputs": { - "transcript": {"type": "inline", "body": "Source text"} - } + "prompt_id": "generic.markdown_summary" } ``` @@ -48,7 +45,7 @@ copyable shape. The smallest valid shape is: | `prompt_id` | yes | Non-blank prompt ID. | | `prompt_version` | no | Prompt version filter. | | `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. | | `model` | no | Runtime model-override object. | | `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 Request decoding rejects malformed JSON, unknown fields at every request level, -and trailing JSON tokens with `400 invalid_json`. A blank `prompt_id` or -empty `inputs` object returns `400 invalid_request`. +and trailing JSON tokens with `400 invalid_json`. A blank `prompt_id` returns +`400 invalid_request`. Omitted or empty `inputs` are passed to Promptkit, which +reports any definition-required or template-referenced inputs. ### Success Response diff --git a/docs/cli.md b/docs/cli.md index 45b01cf..7eeb788 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -39,7 +39,6 @@ Required flags: | Flag | Meaning | | --- | --- | | `--prompt ` | Prompt ID to execute. | -| `--input name=path` | Input file mapping; repeat or use comma-separated mappings. | Optional flags: @@ -49,7 +48,9 @@ Optional flags: | `--prompt-dir ` | Prompt-definition directory override. | | `--profile-dir ` | Custom profile-directory override. | | `--schema-dir ` | Schema base-directory override. | +| `--prompt-version ` | Optional prompt-definition version selector. | | `--profile ` | 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. | | `--out ` | Write generated content to this file instead of stdout. | | `--llm-base-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`. +`--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` ```text scriptorium render [flags] ``` -`--prompt ` and at least one `--input name=path` are required. The -following optional flags are supported: `--config`, `--prompt-dir`, -`--profile-dir`, `--profile`, `--var`, `--out`, `--llm-base-url`, +`--prompt ` is required. The following optional flags are supported: +`--config`, `--prompt-dir`, `--profile-dir`, `--prompt-version`, `--profile`, +`--input`, `--var`, `--out`, `--llm-base-url`, `--model`, `--api-key-env`, `--temperature`, `--max-tokens`, `--top-p`, `--timeout`, and `--format text|json`. Their meanings match the corresponding `run` flags; `--format` selects prepared-run output and otherwise uses diff --git a/docs/internal/adapters.md b/docs/internal/adapters.md index 6c2cf58..c61e8c3 100644 --- a/docs/internal/adapters.md +++ b/docs/internal/adapters.md @@ -29,10 +29,10 @@ described by its tagged ### CLI `run` calls `promptkit.Engine.Run`; `render` calls -`promptkit.Engine.Prepare`. Both share request mapping for prompt/profile -selection, file inputs, variables, and presence-aware execution overrides. -Omitted framework settings remain zero values so Promptkit resolves its own -defaults. +`promptkit.Engine.Prepare`. Both share request mapping for prompt ID/version +and profile selection, optional file inputs, variables, and presence-aware +execution overrides. Omitted framework settings remain zero values so Promptkit +resolves its own defaults and definition-required inputs. `serve` constructs Scriptorium's restricted HTTP artifact reader, injects it with `promptkit.WithArtifactReader`, passes the engine through the HTTP diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 74b257b..fb4cf26 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -95,6 +95,8 @@ private Promptkit integration mechanism has been introduced. ## Stage 2: Remove Definition-Compatibility Restrictions In Run And Render +**Completion: Complete.** + Allow the executable adapters to address every valid directory-backed prompt definition without imposing input requirements of their own. diff --git a/internal/adapter/cli/run.go b/internal/adapter/cli/run.go index 84236e8..e36d6e1 100644 --- a/internal/adapter/cli/run.go +++ b/internal/adapter/cli/run.go @@ -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 ...") - 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) } diff --git a/internal/adapter/cli/run_test.go b/internal/adapter/cli/run_test.go index 7c23ab5..c27d1de 100644 --- a/internal/adapter/cli/run_test.go +++ b/internal/adapter/cli/run_test.go @@ -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 diff --git a/internal/adapter/http/handler.go b/internal/adapter/http/handler.go index 22bd1fd..0c7713c 100644 --- a/internal/adapter/http/handler.go +++ b/internal/adapter/http/handler.go @@ -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, + } } } diff --git a/internal/adapter/http/handler_test.go b/internal/adapter/http/handler_test.go index d350509..46bf869 100644 --- a/internal/adapter/http/handler_test.go +++ b/internal/adapter/http/handler_test.go @@ -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()