Files
scriptorium/internal/adapter/cli/run.go

402 lines
12 KiB
Go

package cli
import (
"context"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
"gitea.maximumdirect.net/eric/scriptorium/internal/prompt"
"gitea.maximumdirect.net/eric/scriptorium/internal/promptdef"
"gitea.maximumdirect.net/eric/scriptorium/internal/usecase"
"gitea.maximumdirect.net/eric/scriptorium/internal/validate"
)
const (
ExitOK = 0
ExitRuntimeError = 1
ExitValidationFailed = 2
)
type runConfig struct {
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
llmBaseURLSet bool
apiKeyEnvSet bool
modelSet bool
temperatureSet bool
maxTokensSet bool
topPSet bool
timeoutSet bool
}
type serveConfig struct {
addr string
promptDir string
profileDir string
schemaDir string
llmBaseURL string
model string
timeout time.Duration
}
type listFlag []string
func (l *listFlag) String() string {
return strings.Join(*l, ",")
}
func (l *listFlag) Set(v string) error {
*l = append(*l, v)
return nil
}
func Run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
printUsage(stderr)
return ExitRuntimeError
}
switch args[0] {
case "run":
return runCommand(args[1:], stdout, stderr)
case "serve":
return serveCommand(args[1:], stderr)
default:
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
printUsage(stderr)
return ExitRuntimeError
}
}
func runCommand(args []string, stdout, stderr io.Writer) int {
cfg, err := parseRunArgs(args)
if err != nil {
fmt.Fprintf(stderr, "run parse error: %v\n", err)
return ExitRuntimeError
}
inputMappings, err := parseMappings(cfg.inputRaw, false)
if err != nil {
fmt.Fprintf(stderr, "input parse error: %v\n", err)
return ExitRuntimeError
}
varMappings := map[string]string{}
if len(cfg.varRaw) > 0 {
varMappings, err = parseMappings(cfg.varRaw, false)
if err != nil {
fmt.Fprintf(stderr, "var parse error: %v\n", err)
return ExitRuntimeError
}
}
inputs := make(map[string]domain.ArtifactRef, len(inputMappings))
for name, path := range inputMappings {
inputs[name] = domain.ArtifactRef{Type: domain.ArtifactRefFile, URI: path}
}
llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
BaseURL: cfg.llmBaseURL,
Model: cfg.model,
Timeout: cfg.timeout,
})
if err != nil {
fmt.Fprintf(stderr, "llm client error: %v\n", err)
return ExitRuntimeError
}
runner := usecase.NewRunner(
promptdef.NewFilesystemRepository(cfg.promptDir),
profile.NewFilesystemRepository(cfg.profileDir),
artifactadapter.NewCompositeReader(),
prompt.NewGoRenderer(),
llmClient,
validate.NewStandardValidator(cfg.schemaDir),
)
var modelOverride *domain.ExecutionTarget
if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.apiKeyEnvSet || cfg.timeoutSet {
modelOverride = &domain.ExecutionTarget{
Endpoint: cfg.llmBaseURL,
Model: cfg.model,
Temperature: cfg.temperature,
MaxTokens: cfg.maxTokens,
TopP: cfg.topP,
APIKeyEnv: cfg.apiKeyEnv,
}
if cfg.timeoutSet {
modelOverride.TimeoutSeconds = int(cfg.timeout.Seconds())
}
}
res, runErr := runner.Run(context.Background(), domain.RunRequest{
PromptID: cfg.promptID,
ProfileID: cfg.profileID,
Inputs: inputs,
Vars: varMappings,
Execution: modelOverride,
})
if runErr != nil {
fmt.Fprintf(stderr, "run error: %v\n", runErr)
return ExitRuntimeError
}
if err := writeOutput(stdout, cfg.outputPath, res.Artifact.Body); err != nil {
fmt.Fprintf(stderr, "output write error: %v\n", err)
return ExitRuntimeError
}
printSummary(stderr, res)
return determineExitCode(nil, res)
}
func serveCommand(args []string, stderr io.Writer) int {
cfg, err := parseServeArgs(args)
if err != nil {
fmt.Fprintf(stderr, "serve parse error: %v\n", err)
return ExitRuntimeError
}
llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{
BaseURL: cfg.llmBaseURL,
Model: cfg.model,
Timeout: cfg.timeout,
})
if err != nil {
fmt.Fprintf(stderr, "llm client error: %v\n", err)
return ExitRuntimeError
}
runner := usecase.NewRunner(
promptdef.NewFilesystemRepository(cfg.promptDir),
profile.NewFilesystemRepository(cfg.profileDir),
artifactadapter.NewCompositeReader(),
prompt.NewGoRenderer(),
llmClient,
validate.NewStandardValidator(cfg.schemaDir),
)
h := httpadapter.NewHandler(runner)
srv := &http.Server{
Addr: cfg.addr,
Handler: h,
ReadHeaderTimeout: defaults.HTTPReadHeaderTimeoutDefault,
}
fmt.Fprintf(stderr, "serving on %s\n", cfg.addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Fprintf(stderr, "server error: %v\n", err)
return ExitRuntimeError
}
return ExitOK
}
func parseRunArgs(args []string) (*runConfig, error) {
cfg := &runConfig{}
fs := flag.NewFlagSet("run", flag.ContinueOnError)
fs.SetOutput(io.Discard)
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.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)")
fs.StringVar(&cfg.outputPath, "out", "", "optional output file path")
fs.StringVar(&cfg.llmBaseURL, "llm-base-url", "", "OpenAI-compatible base URL including /v1")
fs.StringVar(&cfg.apiKeyEnv, "api-key-env", "", "environment variable name containing API key")
fs.StringVar(&cfg.model, "model", "", "model name")
fs.Float64Var(&cfg.temperature, "temperature", 0, "optional temperature override")
fs.IntVar(&cfg.maxTokens, "max-tokens", 0, "optional max tokens override")
fs.Float64Var(&cfg.topP, "top-p", 0, "optional top_p override")
fs.StringVar(&cfg.schemaDir, "schema-dir", defaults.SchemaDirDefault, "base directory for validation schemas")
fs.DurationVar(&cfg.timeout, "timeout", defaults.LLMRequestTimeoutDefault, "LLM request timeout")
fs.StringVar(&cfg.promptID, "prompt-id", "", "deprecated alias for --prompt")
fs.StringVar(&cfg.profileID, "profile-id", "", "deprecated alias for --profile")
if err := fs.Parse(args); err != nil {
return nil, err
}
if fs.NArg() > 0 {
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
}
if strings.TrimSpace(cfg.promptDir) == "" {
return nil, errors.New("--prompt-dir is required")
}
if strings.TrimSpace(cfg.profileDir) == "" {
return nil, errors.New("--profile-dir is required")
}
if strings.TrimSpace(cfg.promptID) == "" {
return nil, errors.New("--prompt is required")
}
if len(cfg.inputRaw) == 0 {
return nil, errors.New("at least one --input is required")
}
cfg.promptDir = filepath.Clean(cfg.promptDir)
cfg.profileDir = filepath.Clean(cfg.profileDir)
cfg.schemaDir = filepath.Clean(cfg.schemaDir)
if cfg.outputPath != "" {
cfg.outputPath = filepath.Clean(cfg.outputPath)
}
cfg.llmBaseURLSet = flagWasSet(fs, "llm-base-url")
cfg.apiKeyEnvSet = flagWasSet(fs, "api-key-env")
cfg.modelSet = flagWasSet(fs, "model")
cfg.temperatureSet = flagWasSet(fs, "temperature")
cfg.maxTokensSet = flagWasSet(fs, "max-tokens")
cfg.topPSet = flagWasSet(fs, "top-p")
cfg.timeoutSet = flagWasSet(fs, "timeout")
return cfg, nil
}
func parseServeArgs(args []string) (*serveConfig, error) {
cfg := &serveConfig{}
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.StringVar(&cfg.addr, "addr", defaults.HTTPAddrDefault, "HTTP listen address")
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.schemaDir, "schema-dir", defaults.SchemaDirDefault, "base directory for validation schemas")
fs.StringVar(&cfg.llmBaseURL, "llm-base-url", "", "OpenAI-compatible base URL including /v1")
fs.StringVar(&cfg.model, "model", "", "optional default model")
fs.DurationVar(&cfg.timeout, "timeout", defaults.LLMRequestTimeoutDefault, "LLM request timeout")
if err := fs.Parse(args); err != nil {
return nil, err
}
if fs.NArg() > 0 {
return nil, fmt.Errorf("unexpected positional args: %v", fs.Args())
}
if strings.TrimSpace(cfg.promptDir) == "" {
return nil, errors.New("--prompt-dir is required")
}
if strings.TrimSpace(cfg.profileDir) == "" {
return nil, errors.New("--profile-dir is required")
}
cfg.promptDir = filepath.Clean(cfg.promptDir)
cfg.profileDir = filepath.Clean(cfg.profileDir)
cfg.schemaDir = filepath.Clean(cfg.schemaDir)
return cfg, nil
}
func parseMappings(raw []string, allowEmptyValue bool) (map[string]string, error) {
out := make(map[string]string)
for _, entry := range raw {
for _, piece := range strings.Split(entry, ",") {
piece = strings.TrimSpace(piece)
if piece == "" {
continue
}
key, value, err := parseMapping(piece)
if err != nil {
return nil, err
}
if !allowEmptyValue && strings.TrimSpace(value) == "" {
return nil, fmt.Errorf("mapping %q has empty value", piece)
}
out[key] = value
}
}
if len(out) == 0 {
return nil, errors.New("no valid mappings provided")
}
return out, nil
}
func parseMapping(value string) (string, string, error) {
idx := strings.IndexRune(value, '=')
if idx <= 0 {
return "", "", fmt.Errorf("invalid mapping %q, expected name=value", value)
}
key := strings.TrimSpace(value[:idx])
val := strings.TrimSpace(value[idx+1:])
if key == "" {
return "", "", fmt.Errorf("invalid mapping %q, empty name", value)
}
return key, val, nil
}
func flagWasSet(fs *flag.FlagSet, name string) bool {
set := false
fs.Visit(func(f *flag.Flag) {
if f.Name == name {
set = true
}
})
return set
}
func writeOutput(stdout io.Writer, outputPath string, body []byte) error {
if outputPath == "" {
_, err := stdout.Write(body)
return err
}
return os.WriteFile(outputPath, body, 0644)
}
func determineExitCode(runErr error, result *domain.RunResult) int {
if runErr != nil {
return ExitRuntimeError
}
if result != nil && result.Validation.Status == domain.ValidationFailed {
return ExitValidationFailed
}
return ExitOK
}
func printSummary(stderr io.Writer, res *domain.RunResult) {
if res == nil {
return
}
fmt.Fprintf(stderr, "prompt=%s@%s selected_profile=%s model=%s validation=%s mode=%s validation_errors=%d prompt_hash=%s inputs=%d usage=%d/%d/%d\n",
res.PromptID,
res.PromptVersion,
res.SelectedProfileID,
res.ModelName,
res.Validation.Status,
res.Validation.Mode,
len(res.Validation.Errors),
res.RenderedPromptHash,
len(res.InputHashes),
res.Usage.PromptTokens,
res.Usage.CompletionTokens,
res.Usage.TotalTokens,
)
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: scriptorium <run|serve> ...")
fmt.Fprintln(w, " run: scriptorium run --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.Fprintf(w, " serve: scriptorium serve --addr %s --prompt-dir DIR --profile-dir DIR [--llm-base-url URL] [--schema-dir DIR] [--model NAME] [--timeout 10m]\n", defaults.HTTPAddrDefault)
}