Add a minimal HTTP API
This commit is contained in:
@@ -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]")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
66
internal/adapter/http/dto.go
Normal file
66
internal/adapter/http/dto.go
Normal file
@@ -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"`
|
||||
}
|
||||
145
internal/adapter/http/handler.go
Normal file
145
internal/adapter/http/handler.go
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
179
internal/adapter/http/handler_test.go
Normal file
179
internal/adapter/http/handler_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user