diff --git a/internal/adapter/cli/run.go b/internal/adapter/cli/run.go index b163abb..120082c 100644 --- a/internal/adapter/cli/run.go +++ b/internal/adapter/cli/run.go @@ -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 ...") + 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]") } diff --git a/internal/adapter/cli/run_test.go b/internal/adapter/cli/run_test.go index cff6378..1592999 100644 --- a/internal/adapter/cli/run_test.go +++ b/internal/adapter/cli/run_test.go @@ -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) diff --git a/internal/adapter/http/dto.go b/internal/adapter/http/dto.go new file mode 100644 index 0000000..74ca1df --- /dev/null +++ b/internal/adapter/http/dto.go @@ -0,0 +1,66 @@ +package httpadapter + +import ( + "time" + + "gitea.maximumdirect.net/eric/scriptorium/internal/domain" +) + +type runRequestDTO struct { + ProfileID string `json:"profile_id"` + ProfileVersion string `json:"profile_version,omitempty"` + Inputs map[string]inputRefDTO `json:"inputs"` + Vars map[string]string `json:"vars,omitempty"` + Model *modelOverrideRequestDTO `json:"model,omitempty"` +} + +type inputRefDTO struct { + Type string `json:"type"` + URI string `json:"uri,omitempty"` + Body string `json:"body,omitempty"` +} + +type modelOverrideRequestDTO struct { + Endpoint string `json:"endpoint,omitempty"` + Model string `json:"model,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + TopP float64 `json:"top_p,omitempty"` +} + +type runResponseDTO struct { + Artifact artifactDTO `json:"artifact"` + Validation domain.ValidationResult `json:"validation"` + Metadata metadataDTO `json:"metadata"` + RawModelOutput string `json:"raw_model_output"` +} + +type artifactDTO struct { + Name string `json:"name"` + ContentType string `json:"content_type"` + Body string `json:"body"` + URI string `json:"uri,omitempty"` + Size int64 `json:"size"` + Hash string `json:"hash"` +} + +type metadataDTO struct { + ProfileID string `json:"profile_id"` + ProfileVersion string `json:"profile_version"` + ModelName string `json:"model_name"` + Endpoint string `json:"endpoint"` + InputHashes map[string]string `json:"input_hashes"` + PromptHash string `json:"prompt_hash"` + Usage domain.TokenUsage `json:"usage"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` +} + +type errorResponse struct { + Error errorBody `json:"error"` +} + +type errorBody struct { + Code string `json:"code"` + Message string `json:"message"` +} diff --git a/internal/adapter/http/handler.go b/internal/adapter/http/handler.go new file mode 100644 index 0000000..252305e --- /dev/null +++ b/internal/adapter/http/handler.go @@ -0,0 +1,145 @@ +package httpadapter + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "gitea.maximumdirect.net/eric/scriptorium/internal/domain" + "gitea.maximumdirect.net/eric/scriptorium/internal/profile" + "gitea.maximumdirect.net/eric/scriptorium/internal/usecase" +) + +type Runner interface { + Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) +} + +type Handler struct { + runner Runner +} + +func NewHandler(runner Runner) *Handler { + return &Handler{runner: runner} +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/runs" { + writeError(w, http.StatusNotFound, "not_found", "route not found") + return + } + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + + var req runRequestDTO + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", fmt.Sprintf("invalid JSON request: %v", err)) + return + } + + if strings.TrimSpace(req.ProfileID) == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "profile_id is required") + return + } + if len(req.Inputs) == 0 { + writeError(w, http.StatusBadRequest, "invalid_request", "inputs is required") + return + } + + mappedInputs := make(map[string]domain.ArtifactRef, len(req.Inputs)) + for name, in := range req.Inputs { + mappedInputs[name] = domain.ArtifactRef{ + Type: domain.ArtifactRefType(in.Type), + URI: in.URI, + Body: in.Body, + } + } + + var model *domain.ModelTarget + if req.Model != nil { + model = &domain.ModelTarget{ + Endpoint: req.Model.Endpoint, + Model: req.Model.Model, + Temperature: req.Model.Temperature, + MaxTokens: req.Model.MaxTokens, + TopP: req.Model.TopP, + } + } + + res, err := h.runner.Run(r.Context(), domain.RunRequest{ + ProfileID: req.ProfileID, + ProfileVersion: req.ProfileVersion, + Inputs: mappedInputs, + Vars: req.Vars, + Model: model, + }) + if err != nil { + status, code := mapRunError(err) + writeError(w, status, code, err.Error()) + return + } + + writeJSON(w, http.StatusOK, runResponseDTO{ + Artifact: artifactDTO{ + Name: res.Artifact.Name, + ContentType: res.Artifact.ContentType, + Body: string(res.Artifact.Body), + URI: res.Artifact.URI, + Size: res.Artifact.Size, + Hash: res.Artifact.Hash, + }, + Validation: res.Validation, + Metadata: metadataDTO{ + ProfileID: res.ProfileID, + ProfileVersion: res.ProfileVersion, + ModelName: res.ModelName, + Endpoint: res.Endpoint, + InputHashes: res.InputHashes, + PromptHash: res.PromptHash, + Usage: res.Usage, + StartTime: res.StartTime, + EndTime: res.EndTime, + }, + RawModelOutput: res.RawOutput, + }) +} + +func mapRunError(err error) (int, string) { + switch { + case errors.Is(err, profile.ErrProfileNotFound): + return http.StatusNotFound, "profile_not_found" + case errors.Is(err, usecase.ErrInvalidRequest): + return http.StatusBadRequest, "invalid_request" + case errors.Is(err, usecase.ErrProfileLoad): + return http.StatusBadRequest, "profile_load_failed" + case errors.Is(err, usecase.ErrArtifactLoad): + return http.StatusBadRequest, "artifact_read_failed" + case errors.Is(err, usecase.ErrPromptRender): + return http.StatusBadRequest, "prompt_render_failed" + case errors.Is(err, usecase.ErrLLMGenerate): + return http.StatusBadGateway, "llm_failed" + case errors.Is(err, usecase.ErrValidation): + return http.StatusInternalServerError, "validation_runtime_failed" + default: + return http.StatusInternalServerError, "internal_error" + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + writeJSON(w, status, errorResponse{ + Error: errorBody{ + Code: code, + Message: message, + }, + }) +} diff --git a/internal/adapter/http/handler_test.go b/internal/adapter/http/handler_test.go new file mode 100644 index 0000000..e45bfb9 --- /dev/null +++ b/internal/adapter/http/handler_test.go @@ -0,0 +1,179 @@ +package httpadapter + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "gitea.maximumdirect.net/eric/scriptorium/internal/domain" + "gitea.maximumdirect.net/eric/scriptorium/internal/profile" + "gitea.maximumdirect.net/eric/scriptorium/internal/usecase" +) + +type fakeRunner struct { + result *domain.RunResult + err error + last domain.RunRequest +} + +func (f *fakeRunner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) { + f.last = req + if f.err != nil { + return nil, f.err + } + return f.result, nil +} + +func TestHandlerPostRunsSuccess(t *testing.T) { + start := time.Now().UTC() + end := start.Add(2 * time.Second) + r := &fakeRunner{result: &domain.RunResult{ + Artifact: domain.Artifact{ + Name: "output", + ContentType: "text/plain", + Body: []byte("hello"), + Size: 5, + Hash: "abc", + }, + Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, + ProfileID: "p1", + ProfileVersion: "1.0.0", + ModelName: "m1", + Endpoint: "http://llm/v1", + InputHashes: map[string]string{"transcript": "h1"}, + PromptHash: "ph", + Usage: domain.TokenUsage{PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3}, + StartTime: start, + EndTime: end, + RawOutput: "hello", + }} + + h := NewHandler(r) + + body := []byte(`{ + "profile_id": "p1", + "inputs": { + "transcript": {"type": "file", "uri": "./t.md"} + }, + "vars": {"k": "v"}, + "model": {"model": "gpt-x"} +}`) + req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body)) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON response: %v", err) + } + + artifact := resp["artifact"].(map[string]any) + if artifact["body"] != "hello" { + t.Fatalf("expected artifact body hello, got %#v", artifact["body"]) + } + if resp["raw_model_output"] != "hello" { + t.Fatalf("expected raw model output hello, got %#v", resp["raw_model_output"]) + } + + if r.last.ProfileID != "p1" { + t.Fatalf("expected request profile_id p1, got %q", r.last.ProfileID) + } + if r.last.Model == nil || r.last.Model.Model != "gpt-x" { + t.Fatalf("expected model override, got %#v", r.last.Model) + } +} + +func TestHandlerInvalidJSON(t *testing.T) { + h := NewHandler(&fakeRunner{}) + req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString("{")) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandlerMissingProfileID(t *testing.T) { + h := NewHandler(&fakeRunner{}) + req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"inputs":{"x":{"type":"file","uri":"a"}}}`)) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestHandlerUsecaseErrorMapping(t *testing.T) { + tests := []struct { + name string + err error + status int + }{ + {name: "profile not found", err: wrap(usecase.ErrProfileLoad, profile.ErrProfileNotFound), status: http.StatusNotFound}, + {name: "artifact", err: wrap(usecase.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest}, + {name: "prompt", err: wrap(usecase.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest}, + {name: "llm", err: wrap(usecase.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway}, + {name: "validation runtime", err: wrap(usecase.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + h := NewHandler(&fakeRunner{err: tc.err}) + req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"profile_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`)) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != tc.status { + t.Fatalf("expected %d, got %d body=%s", tc.status, w.Code, w.Body.String()) + } + }) + } +} + +func TestHandlerValidationFailureStillSuccess(t *testing.T) { + h := NewHandler(&fakeRunner{result: &domain.RunResult{ + Artifact: domain.Artifact{Body: []byte("bad json")}, + RawOutput: "bad json", + Validation: domain.ValidationResult{ + Status: domain.ValidationFailed, + Mode: domain.ValidationJSON, + Errors: []string{"invalid JSON"}, + }, + }}) + + req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"profile_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`)) + w := httptest.NewRecorder() + + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON response: %v", err) + } + validation := resp["validation"].(map[string]any) + if status, ok := validation["Status"].(string); !ok || status != "failed" { + t.Fatalf("expected validation Status=failed, got %#v", validation["Status"]) + } +} + +func wrap(stage error, cause error) error { + return fmt.Errorf("%w: %w", stage, cause) +} diff --git a/internal/usecase/runner.go b/internal/usecase/runner.go index bd2ddb3..b5a15c3 100644 --- a/internal/usecase/runner.go +++ b/internal/usecase/runner.go @@ -60,7 +60,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes prof, err := r.profiles.GetProfile(ctx, req.ProfileID, req.ProfileVersion) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrProfileLoad, err) + return nil, fmt.Errorf("%w: %w", ErrProfileLoad, err) } effectiveModel := mergeModelTarget(prof.ModelDefaults, req.Model) @@ -71,7 +71,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes for name, ref := range req.Inputs { art, readErr := r.artifacts.Read(ctx, ref) if readErr != nil { - return nil, fmt.Errorf("%w: input %q: %v", ErrArtifactLoad, name, readErr) + return nil, fmt.Errorf("%w: input %q: %w", ErrArtifactLoad, name, readErr) } if art.Name == "" { art.Name = name @@ -82,7 +82,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes renderedPrompt, err := r.renderer.Render(ctx, prof, resolvedInputs, req.Vars) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrPromptRender, err) + return nil, fmt.Errorf("%w: %w", ErrPromptRender, err) } promptHash := hashRenderedPrompt(*renderedPrompt) @@ -92,7 +92,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes Target: effectiveModel, }) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrLLMGenerate, err) + return nil, fmt.Errorf("%w: %w", ErrLLMGenerate, err) } outputArtifact := buildOutputArtifact(genResp.Content, effectiveContract.Format) @@ -107,7 +107,7 @@ func (r *Runner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunRes if r.validator != nil && effectiveContract.ValidationMode != domain.ValidationNone { validationResult, err = r.validator.Validate(ctx, &outputArtifact, effectiveContract) if err != nil { - return nil, fmt.Errorf("%w: %v", ErrValidation, err) + return nil, fmt.Errorf("%w: %w", ErrValidation, err) } }