From 280916bf4a01e20dc79ad62a116acf0f8814b0e7 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 28 Jul 2026 00:52:24 +0000 Subject: [PATCH] Move HTTP serving to the public engine --- internal/adapter/cli/run.go | 51 ++--- internal/adapter/http/handler.go | 58 +++--- internal/adapter/http/handler_test.go | 258 ++++++++++++-------------- internal/artifact/reader.go | 134 +------------ 4 files changed, 156 insertions(+), 345 deletions(-) diff --git a/internal/adapter/cli/run.go b/internal/adapter/cli/run.go index 37c9e12..4efe540 100644 --- a/internal/adapter/cli/run.go +++ b/internal/adapter/cli/run.go @@ -14,16 +14,9 @@ import ( "gitea.maximumdirect.net/eric/scriptorium" httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http" - artifactadapter "gitea.maximumdirect.net/eric/scriptorium/internal/artifact" appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" - "gitea.maximumdirect.net/eric/scriptorium/internal/llm" - "gitea.maximumdirect.net/eric/scriptorium/internal/profile/builtin" - "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 ( @@ -206,21 +199,23 @@ func serveCommand(args []string, stderr io.Writer) int { return ExitRuntimeError } - llmClient, err := newOpenAIClient() - if err != nil { - fmt.Fprintf(stderr, "llm client error: %v\n", err) - return ExitRuntimeError - } - - artifactReader, err := artifactadapter.NewRestrictedCompositeReaderWithLimit(cfg.artifactRoot, cfg.maxArtifactBytes) + artifactReader, err := httpadapter.NewRestrictedArtifactReader(cfg.artifactRoot, cfg.maxArtifactBytes) if err != nil { fmt.Fprintf(stderr, "artifact root error: %v\n", err) return ExitRuntimeError } - runner := newRunnerWithArtifactReader(cfg.promptDir, cfg.profileDir, cfg.schemaDir, llmClient, artifactReader) + engine, err := newEngine(&runConfig{ + promptDir: cfg.promptDir, + profileDir: cfg.profileDir, + schemaDir: cfg.schemaDir, + }, scriptorium.WithArtifactReader(artifactReader)) + if err != nil { + fmt.Fprintf(stderr, "engine error: %v\n", err) + return ExitRuntimeError + } - h := httpadapter.NewHandlerWithOptions(runner, httpadapter.HandlerOptions{ + h := httpadapter.NewHandlerWithOptions(engine, httpadapter.HandlerOptions{ MaxRequestBytes: cfg.maxRequestBytes, MaxResponseBytes: cfg.maxResponseBytes, }) @@ -543,30 +538,6 @@ func validateRequiredLibraryDirs(promptDir string) error { return nil } -func newRunner(promptDir, profileDir, schemaDir string, llmClient llm.Client) *usecase.Runner { - return newRunnerWithArtifactReader(promptDir, profileDir, schemaDir, llmClient, artifactadapter.NewCompositeReader()) -} - -func newRunnerWithArtifactReader(promptDir, profileDir, schemaDir string, llmClient llm.Client, artifactReader artifactadapter.Reader) *usecase.Runner { - if artifactReader == nil { - artifactReader = artifactadapter.NewCompositeReader() - } - return usecase.NewRunner( - promptdef.NewFilesystemRepository(promptDir), - builtin.NewRepositoryWithDirectory(profileDir), - artifactReader, - prompt.NewGoRenderer(), - llmClient, - validate.NewStandardValidator(schemaDir), - ) -} - -func newOpenAIClient() (*llm.OpenAICompatibleClient, error) { - return llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{ - Timeout: defaults.LLMRequestTimeoutDefault, - }) -} - func newEngine(cfg *runConfig, options ...scriptorium.Option) (*scriptorium.Engine, error) { return scriptorium.NewEngine(scriptorium.Config{ PromptDir: cfg.promptDir, diff --git a/internal/adapter/http/handler.go b/internal/adapter/http/handler.go index 2952779..4e97ee3 100644 --- a/internal/adapter/http/handler.go +++ b/internal/adapter/http/handler.go @@ -8,16 +8,12 @@ import ( "net/http" "strings" - "gitea.maximumdirect.net/eric/scriptorium/internal/artifact" + "gitea.maximumdirect.net/eric/scriptorium" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" - "gitea.maximumdirect.net/eric/scriptorium/internal/domain" - "gitea.maximumdirect.net/eric/scriptorium/internal/profile" - "gitea.maximumdirect.net/eric/scriptorium/internal/promptdef" - "gitea.maximumdirect.net/eric/scriptorium/internal/usecase" ) type Runner interface { - Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) + Run(ctx context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) } type Handler struct { @@ -85,21 +81,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - mappedInputs := make(map[string]domain.ArtifactRef, len(req.Inputs)) + mappedInputs := make(map[string]scriptorium.ArtifactRef, len(req.Inputs)) for name, in := range req.Inputs { - mappedInputs[name] = domain.ArtifactRef{ - Type: domain.ArtifactRefType(in.Type), + mappedInputs[name] = scriptorium.ArtifactRef{ + Type: scriptorium.ArtifactRefType(in.Type), URI: in.URI, Body: in.Body, } } - var model *domain.ExecutionTargetOverride + var model *scriptorium.ExecutionTargetOverride if req.Model != nil { model = executionTargetOverrideFromModelOverrideDTO(req.Model) } - res, err := h.runner.Run(r.Context(), domain.RunRequest{ + res, err := h.runner.Run(r.Context(), scriptorium.RunRequest{ PromptID: req.PromptID, PromptVersion: req.PromptVersion, ProfileID: req.ProfileID, @@ -156,11 +152,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeLimitedJSON(w, http.StatusOK, resp, h.options.MaxResponseBytes) } -func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *domain.ExecutionTargetOverride { +func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *scriptorium.ExecutionTargetOverride { if dto == nil { return nil } - return &domain.ExecutionTargetOverride{ + return &scriptorium.ExecutionTargetOverride{ Endpoint: dto.Endpoint, Model: dto.Model, Temperature: dto.Temperature, @@ -174,7 +170,7 @@ func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) * } } -func modelParamsDTOFromExecutionTarget(target domain.ExecutionTarget) modelParamsDTO { +func modelParamsDTOFromExecutionTarget(target scriptorium.ExecutionTarget) modelParamsDTO { return modelParamsDTO{ Endpoint: target.Endpoint, Model: target.Model, @@ -189,7 +185,7 @@ func modelParamsDTOFromExecutionTarget(target domain.ExecutionTarget) modelParam } } -func mapValidation(v domain.ValidationResult) validationDTO { +func mapValidation(v scriptorium.ValidationResult) validationDTO { return validationDTO{ Status: string(v.Status), Mode: string(v.Mode), @@ -202,35 +198,31 @@ func mapValidation(v domain.ValidationResult) validationDTO { func mapRunError(err error) (int, string, string) { switch { - case errors.Is(err, promptdef.ErrPromptDefinitionNotFound): + case errors.Is(err, scriptorium.ErrPromptNotFound): return http.StatusNotFound, "prompt_not_found", "prompt definition not found" - case errors.Is(err, profile.ErrProfileNotFound): + case errors.Is(err, scriptorium.ErrProfileNotFound): return http.StatusNotFound, "profile_not_found", "execution profile not found" - case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition): - return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition" - case errors.Is(err, profile.ErrInvalidYAML), errors.Is(err, profile.ErrInvalidProfile), errors.Is(err, profile.ErrRawAPIKeyNotAllowed): - return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile" - case errors.Is(err, usecase.ErrProfileRequired): + case errors.Is(err, scriptorium.ErrProfileRequired): return http.StatusBadRequest, "profile_required", "profile_id is required when prompt default_profile is not set" - case errors.Is(err, usecase.ErrAPIKeyEnvMissing): + case errors.Is(err, scriptorium.ErrAPIKeyEnvMissing): return http.StatusBadRequest, "api_key_env_missing", "api_key_env is set but the environment variable is missing" - case errors.Is(err, usecase.ErrInvalidRequest): - return http.StatusBadRequest, "invalid_request", "invalid run request" - case errors.Is(err, usecase.ErrPromptLoad): + case errors.Is(err, scriptorium.ErrPromptLoad): return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition" - case errors.Is(err, usecase.ErrProfileLoad): + case errors.Is(err, scriptorium.ErrProfileLoad): return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile" - case errors.Is(err, artifact.ErrFileNotAllowed), errors.Is(err, artifact.ErrFileOutsideRoot): + case errors.Is(err, scriptorium.ErrInvalidRequest): + return http.StatusBadRequest, "invalid_request", "invalid run request" + case errors.Is(err, ErrFileNotAllowed), errors.Is(err, ErrFileOutsideRoot): return http.StatusBadRequest, "artifact_not_allowed", "file input artifact is not allowed" - case errors.Is(err, artifact.ErrFileTooLarge): + case errors.Is(err, ErrFileTooLarge): return http.StatusRequestEntityTooLarge, "artifact_too_large", "file input artifact is too large" - case errors.Is(err, usecase.ErrArtifactLoad): + case errors.Is(err, scriptorium.ErrArtifactLoad): return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact" - case errors.Is(err, usecase.ErrPromptRender): + case errors.Is(err, scriptorium.ErrPromptRender): return http.StatusBadRequest, "prompt_render_failed", "failed to render prompt" - case errors.Is(err, usecase.ErrLLMGenerate): + case errors.Is(err, scriptorium.ErrLLMGenerate): return http.StatusBadGateway, "llm_failed", "model generation request failed" - case errors.Is(err, usecase.ErrValidation): + case errors.Is(err, scriptorium.ErrValidation): return http.StatusInternalServerError, "validation_runtime_failed", "validation runtime failed" default: return http.StatusInternalServerError, "internal_error", "internal server error" diff --git a/internal/adapter/http/handler_test.go b/internal/adapter/http/handler_test.go index d35e9bd..c07838a 100644 --- a/internal/adapter/http/handler_test.go +++ b/internal/adapter/http/handler_test.go @@ -14,21 +14,16 @@ import ( "testing" "time" - "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" - "gitea.maximumdirect.net/eric/scriptorium/internal/promptdef" - "gitea.maximumdirect.net/eric/scriptorium/internal/usecase" + "gitea.maximumdirect.net/eric/scriptorium" ) type fakeRunner struct { - result *domain.RunResult + result *scriptorium.RunResult err error - last domain.RunRequest + last scriptorium.RunRequest } -func (f *fakeRunner) Run(ctx context.Context, req domain.RunRequest) (*domain.RunResult, error) { +func (f *fakeRunner) Run(ctx context.Context, req scriptorium.RunRequest) (*scriptorium.RunResult, error) { f.last = req if f.err != nil { return nil, f.err @@ -42,7 +37,7 @@ func TestMaintainedHTTPRunExampleMatchesRequestContract(t *testing.T) { t.Fatalf("read maintained HTTP request example: %v", err) } - runner := &fakeRunner{result: &domain.RunResult{}} + runner := &fakeRunner{result: &scriptorium.RunResult{}} h := NewHandler(runner) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body)) w := httptest.NewRecorder() @@ -68,38 +63,10 @@ func TestMaintainedHTTPRunExampleMatchesRequestContract(t *testing.T) { assertHTTPErrorCode(t, invalidW, http.StatusBadRequest, "invalid_json") } -type handlerPromptRepo struct { - def *domain.PromptDefinition -} - -func (r handlerPromptRepo) GetPromptDefinition(ctx context.Context, id string, version string) (*domain.PromptDefinition, error) { - return r.def, nil -} - -type handlerProfileRepo struct { - profile *domain.ExecutionProfile -} - -func (r handlerProfileRepo) GetProfile(ctx context.Context, id string) (*domain.ExecutionProfile, error) { - return r.profile, nil -} - -type handlerArtifactReader struct{} - -func (handlerArtifactReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) { - return &domain.Artifact{Name: "input", Body: []byte("input"), Hash: "hash"}, nil -} - -type handlerRenderer struct{} - -func (handlerRenderer) Render(ctx context.Context, definition *domain.PromptDefinition, inputs map[string]*domain.Artifact, vars map[string]string) (*domain.RenderedPrompt, error) { - return &domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}}, nil -} - type handlerLLMClient struct{} -func (handlerLLMClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) { - return &domain.GenerateResponse{Content: "ok"}, nil +func (handlerLLMClient) Generate(ctx context.Context, req scriptorium.GenerateRequest) (*scriptorium.GenerateResponse, error) { + return &scriptorium.GenerateResponse{Content: "ok"}, nil } func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) { @@ -108,16 +75,16 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) { const envName = "SCRIPTORIUM_API_KEY" const secret = "never-include-me" - r := &fakeRunner{result: &domain.RunResult{ + r := &fakeRunner{result: &scriptorium.RunResult{ RunID: "11111111-1111-4111-8111-111111111111", - Artifact: domain.Artifact{ + Artifact: scriptorium.Artifact{ Name: "output", ContentType: "text/plain", Body: []byte("hello"), Size: 5, Hash: "abc", }, - Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, + Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true}, PromptID: "prompt-1", PromptVersion: "1.0.0", PromptHash: "phash", @@ -125,7 +92,7 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) { SelectedProfileID: "exec-default", ModelName: "m1", Endpoint: "http://llm/v1", - EffectiveModelParams: domain.ExecutionTarget{ + EffectiveModelParams: scriptorium.ExecutionTarget{ Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.2, @@ -136,7 +103,7 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) { APIKeyEnv: envName, }, InputHashes: map[string]string{"transcript": "h1"}, - Usage: domain.TokenUsage{ + Usage: scriptorium.TokenUsage{ PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3, @@ -325,13 +292,13 @@ func TestHandlerFileRefsOutsideArtifactRootAreRejected(t *testing.T) { } func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) { - r := &fakeRunner{result: &domain.RunResult{ - Artifact: domain.Artifact{Body: []byte("ok")}, + r := &fakeRunner{result: &scriptorium.RunResult{ + Artifact: scriptorium.Artifact{Body: []byte("ok")}, PromptID: "prompt-1", PromptVersion: "1.0.0", SelectedProfileID: "prompt-default", - Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, - EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, + Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true}, + EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, }} h := NewHandler(r) @@ -360,10 +327,10 @@ func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) { } func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) { - r := &fakeRunner{result: &domain.RunResult{ - Artifact: domain.Artifact{Body: []byte("ok")}, - Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, - EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, + r := &fakeRunner{result: &scriptorium.RunResult{ + Artifact: scriptorium.Artifact{Body: []byte("ok")}, + Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true}, + EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, }} h := NewHandler(r) @@ -420,10 +387,10 @@ func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) { } func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) { - r := &fakeRunner{result: &domain.RunResult{ - Artifact: domain.Artifact{Body: []byte("ok")}, - Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, - EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, + r := &fakeRunner{result: &scriptorium.RunResult{ + Artifact: scriptorium.Artifact{Body: []byte("ok")}, + Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true}, + EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, }} h := NewHandler(r) @@ -464,10 +431,10 @@ func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) { } func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T) { - r := &fakeRunner{result: &domain.RunResult{ - Artifact: domain.Artifact{Body: []byte("ok")}, - Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, - EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0}, + r := &fakeRunner{result: &scriptorium.RunResult{ + Artifact: scriptorium.Artifact{Body: []byte("ok")}, + Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true}, + EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0}, }} h := NewHandler(r) @@ -491,10 +458,10 @@ func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T) } func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) { - r := &fakeRunner{result: &domain.RunResult{ - Artifact: domain.Artifact{Body: []byte("ok")}, - Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, - EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.7}, + r := &fakeRunner{result: &scriptorium.RunResult{ + Artifact: scriptorium.Artifact{Body: []byte("ok")}, + Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true}, + EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.7}, }} h := NewHandler(r) @@ -528,16 +495,16 @@ func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) { } func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing.T) { - r := &fakeRunner{result: &domain.RunResult{ - Artifact: domain.Artifact{ + r := &fakeRunner{result: &scriptorium.RunResult{ + Artifact: scriptorium.Artifact{ Name: "output", ContentType: "text/plain", Body: []byte("ok"), Size: 2, Hash: "abc", }, - Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, - EffectiveModelParams: domain.ExecutionTarget{ + Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true}, + EffectiveModelParams: scriptorium.ExecutionTarget{ Endpoint: "http://llm/v1", Model: "gpt-test", Temperature: 0.4, @@ -657,10 +624,10 @@ func TestHandlerMalformedJSONBelowLimitStillBadRequest(t *testing.T) { } func TestHandlerResponseTooLarge(t *testing.T) { - h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{ - Artifact: domain.Artifact{Body: []byte(strings.Repeat("x", 128))}, - Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, - EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, + h := NewHandlerWithOptions(&fakeRunner{result: &scriptorium.RunResult{ + Artifact: scriptorium.Artifact{Body: []byte(strings.Repeat("x", 128))}, + Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true}, + EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, }}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 64}) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`)) w := httptest.NewRecorder() @@ -671,11 +638,11 @@ func TestHandlerResponseTooLarge(t *testing.T) { } func TestHandlerRawOutputDoesNotBypassResponseLimit(t *testing.T) { - h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{ - Artifact: domain.Artifact{Body: []byte("ok")}, + h := NewHandlerWithOptions(&fakeRunner{result: &scriptorium.RunResult{ + Artifact: scriptorium.Artifact{Body: []byte("ok")}, RawOutput: strings.Repeat("raw", 80), - Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true}, - EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, + Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true}, + EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"}, }}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 128}) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{ "prompt_id":"p", @@ -709,35 +676,12 @@ func TestHandlerMissingPromptID(t *testing.T) { } } -func TestHandlerReservedExtraParamsThroughRunnerMapsToInvalidRequest(t *testing.T) { - llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{}) - if err != nil { - t.Fatal(err) - } - runner := usecase.NewRunner( - handlerPromptRepo{def: &domain.PromptDefinition{ - ID: "p", - Version: "1", - DefaultProfile: "exec", - Templates: []domain.PromptMessageTemplate{{Role: "user", Content: "hi"}}, - OutputFormat: domain.FormatText, - Validation: domain.OutputContract{Format: domain.FormatText, ValidationMode: domain.ValidationNone}, - }}, - handlerProfileRepo{profile: &domain.ExecutionProfile{ - ID: "exec", - Endpoint: "http://example.invalid/v1", - Model: "model", - }}, - handlerArtifactReader{}, - handlerRenderer{}, - llmClient, - nil, - ) - h := NewHandler(runner) +func TestHandlerReservedExtraParamsThroughEngineMapsToInvalidRequest(t *testing.T) { + h := NewHandler(newHandlerEngineWithDefaultClient(t)) req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{ "prompt_id":"p", - "inputs":{"x":{"type":"file","uri":"a"}}, + "inputs":{"x":{"type":"inline","body":"input"}}, "model":{"extra_params":{"model":"collision"}} }`)) w := httptest.NewRecorder() @@ -757,7 +701,7 @@ func TestHandlerReservedExtraParamsThroughRunnerMapsToInvalidRequest(t *testing. } } -func TestHandlerUsecaseErrorMapping(t *testing.T) { +func TestHandlerPublicErrorMapping(t *testing.T) { tests := []struct { name string err error @@ -766,18 +710,20 @@ func TestHandlerUsecaseErrorMapping(t *testing.T) { message string avoidCause string }{ - {name: "prompt not found", err: wrap(usecase.ErrPromptLoad, promptdef.ErrPromptDefinitionNotFound), status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"}, - {name: "prompt load invalid", err: wrap(usecase.ErrPromptLoad, promptdef.ErrInvalidPromptDefinition), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition"}, - {name: "prompt load generic", err: wrap(usecase.ErrPromptLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition", avoidCause: "read failed"}, - {name: "missing profile/default", err: wrap(usecase.ErrInvalidRequest, usecase.ErrProfileRequired), status: http.StatusBadRequest, code: "profile_required", message: "profile_id is required when prompt default_profile is not set"}, - {name: "profile not found", err: wrap(usecase.ErrProfileLoad, profile.ErrProfileNotFound), status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"}, - {name: "profile invalid", err: wrap(usecase.ErrProfileLoad, profile.ErrInvalidProfile), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile"}, - {name: "profile load generic", err: wrap(usecase.ErrProfileLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile", avoidCause: "read failed"}, - {name: "api key env missing", err: wrap(usecase.ErrInvalidRequest, usecase.ErrAPIKeyEnvMissing), status: http.StatusBadRequest, code: "api_key_env_missing", message: "api_key_env is set but the environment variable is missing"}, - {name: "artifact", err: wrap(usecase.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "artifact_read_failed", message: "failed to read input artifact", avoidCause: "read failed"}, - {name: "prompt render", err: wrap(usecase.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest, code: "prompt_render_failed", message: "failed to render prompt", avoidCause: "render failed"}, - {name: "llm", err: wrap(usecase.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "llm failed"}, - {name: "validation runtime", err: wrap(usecase.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError, code: "validation_runtime_failed", message: "validation runtime failed", avoidCause: "validator broke"}, + {name: "prompt not found", err: scriptorium.ErrPromptNotFound, status: http.StatusNotFound, code: "prompt_not_found", message: "prompt definition not found"}, + {name: "prompt load", err: wrap(scriptorium.ErrPromptLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "prompt_load_failed", message: "failed to load prompt definition", avoidCause: "read failed"}, + {name: "missing profile/default", err: wrap(scriptorium.ErrProfileRequired, scriptorium.ErrInvalidRequest), status: http.StatusBadRequest, code: "profile_required", message: "profile_id is required when prompt default_profile is not set"}, + {name: "profile not found", err: scriptorium.ErrProfileNotFound, status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"}, + {name: "profile load", err: wrap(scriptorium.ErrProfileLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile", avoidCause: "read failed"}, + {name: "api key env missing", err: wrap(scriptorium.ErrAPIKeyEnvMissing, scriptorium.ErrInvalidRequest), status: http.StatusBadRequest, code: "api_key_env_missing", message: "api_key_env is set but the environment variable is missing"}, + {name: "invalid request", err: scriptorium.ErrInvalidRequest, status: http.StatusBadRequest, code: "invalid_request", message: "invalid run request"}, + {name: "file denied", err: ErrFileNotAllowed, status: http.StatusBadRequest, code: "artifact_not_allowed", message: "file input artifact is not allowed"}, + {name: "file outside root", err: ErrFileOutsideRoot, status: http.StatusBadRequest, code: "artifact_not_allowed", message: "file input artifact is not allowed"}, + {name: "file too large", err: ErrFileTooLarge, status: http.StatusRequestEntityTooLarge, code: "artifact_too_large", message: "file input artifact is too large"}, + {name: "artifact", err: wrap(scriptorium.ErrArtifactLoad, fmt.Errorf("read failed")), status: http.StatusBadRequest, code: "artifact_read_failed", message: "failed to read input artifact", avoidCause: "read failed"}, + {name: "prompt render", err: wrap(scriptorium.ErrPromptRender, fmt.Errorf("render failed")), status: http.StatusBadRequest, code: "prompt_render_failed", message: "failed to render prompt", avoidCause: "render failed"}, + {name: "llm", err: wrap(scriptorium.ErrLLMGenerate, fmt.Errorf("llm failed")), status: http.StatusBadGateway, code: "llm_failed", message: "model generation request failed", avoidCause: "llm failed"}, + {name: "validation runtime", err: wrap(scriptorium.ErrValidation, fmt.Errorf("validator broke")), status: http.StatusInternalServerError, code: "validation_runtime_failed", message: "validation runtime failed", avoidCause: "validator broke"}, } for _, tc := range tests { @@ -830,12 +776,12 @@ func TestHandlerRawAPIKeyRejectedByStrictJSON(t *testing.T) { } func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) { - h := NewHandler(&fakeRunner{result: &domain.RunResult{ - Artifact: domain.Artifact{Body: []byte("bad json")}, + h := NewHandler(&fakeRunner{result: &scriptorium.RunResult{ + Artifact: scriptorium.Artifact{Body: []byte("bad json")}, RawOutput: "bad json", - Validation: domain.ValidationResult{ - Status: domain.ValidationFailed, - Mode: domain.ValidationJSON, + Validation: scriptorium.ValidationResult{ + Status: scriptorium.ValidationFailed, + Mode: scriptorium.ValidationJSON, Errors: []string{"invalid JSON"}, }, }}) @@ -889,30 +835,58 @@ func newArtifactRootHandler(t *testing.T, root string) *Handler { func newArtifactRootHandlerWithLimit(t *testing.T, root string, maxArtifactBytes int64) *Handler { t.Helper() - reader, err := artifact.NewRestrictedCompositeReaderWithLimit(root, maxArtifactBytes) + reader, err := NewRestrictedArtifactReader(root, maxArtifactBytes) if err != nil { t.Fatalf("expected restricted artifact reader: %v", err) } - runner := usecase.NewRunner( - handlerPromptRepo{def: &domain.PromptDefinition{ - ID: "p", - Version: "1", - DefaultProfile: "exec", - Templates: []domain.PromptMessageTemplate{{Role: "user", Content: "hi"}}, - OutputFormat: domain.FormatText, - Validation: domain.OutputContract{Format: domain.FormatText, ValidationMode: domain.ValidationNone}, - }}, - handlerProfileRepo{profile: &domain.ExecutionProfile{ - ID: "exec", - Endpoint: "http://example.invalid/v1", - Model: "model", - }}, - reader, - handlerRenderer{}, - handlerLLMClient{}, - nil, - ) - return NewHandler(runner) + return NewHandler(newHandlerEngine(t, scriptorium.WithArtifactReader(reader))) +} + +func newHandlerEngine(t *testing.T, options ...scriptorium.Option) *scriptorium.Engine { + t.Helper() + + return newHandlerEngineWithOptions(t, append(options, scriptorium.WithLLMClient(handlerLLMClient{}))...) +} + +func newHandlerEngineWithDefaultClient(t *testing.T, options ...scriptorium.Option) *scriptorium.Engine { + t.Helper() + + return newHandlerEngineWithOptions(t, options...) +} + +func newHandlerEngineWithOptions(t *testing.T, options ...scriptorium.Option) *scriptorium.Engine { + t.Helper() + + promptDir := t.TempDir() + profileDir := t.TempDir() + if err := os.WriteFile(filepath.Join(promptDir, "prompt.yaml"), []byte(`id: p +version: "1" +default_profile: exec +messages: + - role: user + content: "hi" +output: + format: text + validation_mode: none + repair_attempts: 0 +`), 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 := scriptorium.NewEngine(scriptorium.Config{ + PromptDir: promptDir, + ProfileDir: profileDir, + }, options...) + if err != nil { + t.Fatalf("construct public engine: %v", err) + } + return engine } func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) { diff --git a/internal/artifact/reader.go b/internal/artifact/reader.go index 3d7f2a9..eb881fb 100644 --- a/internal/artifact/reader.go +++ b/internal/artifact/reader.go @@ -5,22 +5,19 @@ import ( "crypto/sha256" "errors" "fmt" - "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" - "gitea.maximumdirect.net/eric/scriptorium/internal/domain" "io" "mime" "os" "path/filepath" - "strings" + + "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" + "gitea.maximumdirect.net/eric/scriptorium/internal/domain" ) var ( ErrUnsupportedRefType = errors.New("unsupported artifact reference type") ErrMissingInlineBody = errors.New("missing body for inline artifact") ErrMissingFilePath = errors.New("missing file path for file artifact") - ErrFileNotAllowed = errors.New("file artifact references are not allowed") - ErrFileOutsideRoot = errors.New("file artifact path is outside artifact root") - ErrFileTooLarge = errors.New("file artifact exceeds size limit") ) // Reader resolves artifact references into actual artifacts. @@ -41,27 +38,6 @@ func NewCompositeReader() Reader { } } -// NewRestrictedCompositeReader is a temporary bridge for legacy adapter wiring. -// It has no compatibility promise and will be removed when those adapters use -// the public HTTP artifact reader. -func NewRestrictedCompositeReader(root string) (Reader, error) { - return NewRestrictedCompositeReaderWithLimit(root, 0) -} - -// NewRestrictedCompositeReaderWithLimit is a temporary bridge for legacy -// adapter wiring. It has no compatibility promise and will be removed when -// those adapters use the public HTTP artifact reader. -func NewRestrictedCompositeReaderWithLimit(root string, maxBytes int64) (Reader, error) { - fileReader, err := newRestrictedFileReader(root, maxBytes) - if err != nil { - return nil, err - } - return &CompositeReader{ - inlineReader: &inlineReader{}, - fileReader: fileReader, - }, nil -} - func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) { select { case <-ctx.Done(): @@ -118,119 +94,17 @@ func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain. return readFileArtifact(ref.URI) } -type deniedFileReader struct{} - -func (r deniedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - - if ref.URI == "" { - return nil, ErrMissingFilePath - } - return nil, ErrFileNotAllowed -} - -type restrictedFileReader struct { - root string - maxBytes int64 -} - -func newRestrictedFileReader(root string, maxBytes int64) (Reader, error) { - if maxBytes < 0 { - return nil, fmt.Errorf("artifact size limit must be greater than or equal to 0") - } - cleanRoot := strings.TrimSpace(root) - if cleanRoot == "" { - return deniedFileReader{}, nil - } - absRoot, err := filepath.Abs(filepath.Clean(cleanRoot)) - if err != nil { - return nil, fmt.Errorf("resolve artifact root: %w", err) - } - return &restrictedFileReader{root: absRoot, maxBytes: maxBytes}, nil -} - -func (r *restrictedFileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) { - select { - case <-ctx.Done(): - return nil, ctx.Err() - default: - } - - if ref.URI == "" { - return nil, ErrMissingFilePath - } - - path, err := r.resolveLexicalPath(ref.URI) - if err != nil { - return nil, err - } - return readFileArtifactWithLimit(path, r.maxBytes) -} - -// resolveLexicalPath checks cleaned path containment without resolving symlinks. -func (r *restrictedFileReader) resolveLexicalPath(rawPath string) (string, error) { - cleanPath := filepath.Clean(strings.TrimSpace(rawPath)) - var candidate string - if filepath.IsAbs(cleanPath) { - candidate = cleanPath - } else { - candidate = filepath.Join(r.root, cleanPath) - } - - absCandidate, err := filepath.Abs(candidate) - if err != nil { - return "", fmt.Errorf("resolve artifact path: %w", err) - } - absCandidate = filepath.Clean(absCandidate) - - rel, err := filepath.Rel(r.root, absCandidate) - if err != nil { - return "", fmt.Errorf("compare artifact path to root: %w", err) - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) { - return "", ErrFileOutsideRoot - } - return absCandidate, nil -} - func readFileArtifact(path string) (*domain.Artifact, error) { - return readFileArtifactWithLimit(path, 0) -} - -func readFileArtifactWithLimit(path string, maxBytes int64) (*domain.Artifact, error) { - if maxBytes < 0 { - return nil, fmt.Errorf("file size limit must be greater than or equal to 0") - } - file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to read file %s: %w", path, err) } defer file.Close() - info, err := file.Stat() - if err != nil { - return nil, fmt.Errorf("failed to stat file %s: %w", path, err) - } - if maxBytes > 0 && info.Size() > maxBytes { - return nil, ErrFileTooLarge - } - - var reader io.Reader = file - if maxBytes > 0 { - reader = io.LimitReader(file, maxBytes+1) - } - data, err := io.ReadAll(reader) + data, err := io.ReadAll(file) if err != nil { return nil, fmt.Errorf("failed to read file %s: %w", path, err) } - if maxBytes > 0 && int64(len(data)) > maxBytes { - return nil, ErrFileTooLarge - } contentType := mime.TypeByExtension(filepath.Ext(path)) if contentType == "" {