Add a minimal HTTP API

This commit is contained in:
2026-05-04 21:19:29 -05:00
parent 526ee463f2
commit eac6b69217
6 changed files with 507 additions and 8 deletions

View File

@@ -6,12 +6,14 @@ import (
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
"gitea.maximumdirect.net/eric/scriptorium/internal/llm"
"gitea.maximumdirect.net/eric/scriptorium/internal/profile"
@@ -40,6 +42,16 @@ type runConfig struct {
schemaDir string
}
type serveConfig struct {
addr string
profileDir string
schemaDir string
llmBaseURL string
llmAPIKey string
model string
timeout time.Duration
}
type listFlag []string
func (l *listFlag) String() string {
@@ -60,6 +72,8 @@ func Run(args []string, stdout, stderr io.Writer) int {
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)
@@ -103,7 +117,7 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
runner := usecase.NewRunner(
profile.NewFilesystemRepository(cfg.profileDir),
artifact.NewCompositeReader(),
artifactadapter.NewCompositeReader(),
prompt.NewGoRenderer(),
llmClient,
validate.NewStandardValidator(cfg.schemaDir),
@@ -134,6 +148,47 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
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,
APIKey: cfg.llmAPIKey,
Model: cfg.model,
Timeout: cfg.timeout,
})
if err != nil {
fmt.Fprintf(stderr, "llm client error: %v\n", err)
return ExitRuntimeError
}
runner := usecase.NewRunner(
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: 10 * time.Second,
}
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)
@@ -183,6 +238,38 @@ func parseRunArgs(args []string) (*runConfig, error) {
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", ":8080", "HTTP listen address")
fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing prompt profile YAML files")
fs.StringVar(&cfg.schemaDir, "schema-dir", ".", "base directory for validation schemas")
fs.StringVar(&cfg.llmBaseURL, "llm-base-url", "", "OpenAI-compatible base URL including /v1")
fs.StringVar(&cfg.llmAPIKey, "llm-api-key", "", "optional API key")
fs.StringVar(&cfg.model, "model", "", "optional default model")
fs.DurationVar(&cfg.timeout, "timeout", 60*time.Second, "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.profileDir) == "" {
return nil, errors.New("--profile-dir is required")
}
if strings.TrimSpace(cfg.llmBaseURL) == "" {
return nil, errors.New("--llm-base-url is required")
}
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 {
@@ -258,5 +345,7 @@ func printSummary(stderr io.Writer, res *domain.RunResult) {
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: scriptorium run --profile-dir DIR --profile-id ID --input name=path [--input ...] --llm-base-url URL --model NAME [--var k=v] [--out path]")
fmt.Fprintln(w, "usage: scriptorium <run|serve> ...")
fmt.Fprintln(w, " run: scriptorium run --profile-dir DIR --profile-id ID --input name=path [--input ...] --llm-base-url URL --model NAME [--var k=v] [--out path]")
fmt.Fprintln(w, " serve: scriptorium serve --addr :8080 --profile-dir DIR --llm-base-url URL [--schema-dir DIR] [--llm-api-key KEY] [--model NAME]")
}

View File

@@ -74,6 +74,26 @@ func TestParseRunArgsRequiredFlags(t *testing.T) {
}
}
func TestParseServeArgsRequiredFlags(t *testing.T) {
_, err := parseServeArgs([]string{"--llm-base-url", "http://x/v1"})
if err == nil {
t.Fatal("expected missing --profile-dir error")
}
_, err = parseServeArgs([]string{"--profile-dir", "./profiles"})
if err == nil {
t.Fatal("expected missing --llm-base-url error")
}
cfg, err := parseServeArgs([]string{"--profile-dir", "./profiles", "--llm-base-url", "http://x/v1"})
if err != nil {
t.Fatalf("expected valid serve args, got %v", err)
}
if cfg.addr != ":8080" {
t.Fatalf("expected default addr :8080, got %q", cfg.addr)
}
}
func TestDetermineExitCode(t *testing.T) {
if got := determineExitCode(errors.New("boom"), nil); got != ExitRuntimeError {
t.Fatalf("expected runtime exit code, got %d", got)