Move HTTP serving to the public engine
This commit is contained in:
@@ -14,16 +14,9 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium"
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http"
|
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"
|
appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
|
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 (
|
const (
|
||||||
@@ -206,21 +199,23 @@ func serveCommand(args []string, stderr io.Writer) int {
|
|||||||
return ExitRuntimeError
|
return ExitRuntimeError
|
||||||
}
|
}
|
||||||
|
|
||||||
llmClient, err := newOpenAIClient()
|
artifactReader, err := httpadapter.NewRestrictedArtifactReader(cfg.artifactRoot, cfg.maxArtifactBytes)
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(stderr, "llm client error: %v\n", err)
|
|
||||||
return ExitRuntimeError
|
|
||||||
}
|
|
||||||
|
|
||||||
artifactReader, err := artifactadapter.NewRestrictedCompositeReaderWithLimit(cfg.artifactRoot, cfg.maxArtifactBytes)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(stderr, "artifact root error: %v\n", err)
|
fmt.Fprintf(stderr, "artifact root error: %v\n", err)
|
||||||
return ExitRuntimeError
|
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,
|
MaxRequestBytes: cfg.maxRequestBytes,
|
||||||
MaxResponseBytes: cfg.maxResponseBytes,
|
MaxResponseBytes: cfg.maxResponseBytes,
|
||||||
})
|
})
|
||||||
@@ -543,30 +538,6 @@ func validateRequiredLibraryDirs(promptDir string) error {
|
|||||||
return nil
|
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) {
|
func newEngine(cfg *runConfig, options ...scriptorium.Option) (*scriptorium.Engine, error) {
|
||||||
return scriptorium.NewEngine(scriptorium.Config{
|
return scriptorium.NewEngine(scriptorium.Config{
|
||||||
PromptDir: cfg.promptDir,
|
PromptDir: cfg.promptDir,
|
||||||
|
|||||||
@@ -8,16 +8,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"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/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 {
|
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 {
|
type Handler struct {
|
||||||
@@ -85,21 +81,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
for name, in := range req.Inputs {
|
||||||
mappedInputs[name] = domain.ArtifactRef{
|
mappedInputs[name] = scriptorium.ArtifactRef{
|
||||||
Type: domain.ArtifactRefType(in.Type),
|
Type: scriptorium.ArtifactRefType(in.Type),
|
||||||
URI: in.URI,
|
URI: in.URI,
|
||||||
Body: in.Body,
|
Body: in.Body,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var model *domain.ExecutionTargetOverride
|
var model *scriptorium.ExecutionTargetOverride
|
||||||
if req.Model != nil {
|
if req.Model != nil {
|
||||||
model = executionTargetOverrideFromModelOverrideDTO(req.Model)
|
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,
|
PromptID: req.PromptID,
|
||||||
PromptVersion: req.PromptVersion,
|
PromptVersion: req.PromptVersion,
|
||||||
ProfileID: req.ProfileID,
|
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)
|
writeLimitedJSON(w, http.StatusOK, resp, h.options.MaxResponseBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *domain.ExecutionTargetOverride {
|
func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *scriptorium.ExecutionTargetOverride {
|
||||||
if dto == nil {
|
if dto == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &domain.ExecutionTargetOverride{
|
return &scriptorium.ExecutionTargetOverride{
|
||||||
Endpoint: dto.Endpoint,
|
Endpoint: dto.Endpoint,
|
||||||
Model: dto.Model,
|
Model: dto.Model,
|
||||||
Temperature: dto.Temperature,
|
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{
|
return modelParamsDTO{
|
||||||
Endpoint: target.Endpoint,
|
Endpoint: target.Endpoint,
|
||||||
Model: target.Model,
|
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{
|
return validationDTO{
|
||||||
Status: string(v.Status),
|
Status: string(v.Status),
|
||||||
Mode: string(v.Mode),
|
Mode: string(v.Mode),
|
||||||
@@ -202,35 +198,31 @@ func mapValidation(v domain.ValidationResult) validationDTO {
|
|||||||
|
|
||||||
func mapRunError(err error) (int, string, string) {
|
func mapRunError(err error) (int, string, string) {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, promptdef.ErrPromptDefinitionNotFound):
|
case errors.Is(err, scriptorium.ErrPromptNotFound):
|
||||||
return http.StatusNotFound, "prompt_not_found", "prompt definition not found"
|
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"
|
return http.StatusNotFound, "profile_not_found", "execution profile not found"
|
||||||
case errors.Is(err, promptdef.ErrInvalidYAML), errors.Is(err, promptdef.ErrInvalidPromptDefinition):
|
case errors.Is(err, scriptorium.ErrProfileRequired):
|
||||||
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):
|
|
||||||
return http.StatusBadRequest, "profile_required", "profile_id is required when prompt default_profile is not set"
|
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"
|
return http.StatusBadRequest, "api_key_env_missing", "api_key_env is set but the environment variable is missing"
|
||||||
case errors.Is(err, usecase.ErrInvalidRequest):
|
case errors.Is(err, scriptorium.ErrPromptLoad):
|
||||||
return http.StatusBadRequest, "invalid_request", "invalid run request"
|
|
||||||
case errors.Is(err, usecase.ErrPromptLoad):
|
|
||||||
return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition"
|
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"
|
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"
|
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"
|
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"
|
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"
|
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"
|
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"
|
return http.StatusInternalServerError, "validation_runtime_failed", "validation runtime failed"
|
||||||
default:
|
default:
|
||||||
return http.StatusInternalServerError, "internal_error", "internal server error"
|
return http.StatusInternalServerError, "internal_error", "internal server error"
|
||||||
|
|||||||
@@ -14,21 +14,16 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/artifact"
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
"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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type fakeRunner struct {
|
type fakeRunner struct {
|
||||||
result *domain.RunResult
|
result *scriptorium.RunResult
|
||||||
err error
|
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
|
f.last = req
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return nil, f.err
|
return nil, f.err
|
||||||
@@ -42,7 +37,7 @@ func TestMaintainedHTTPRunExampleMatchesRequestContract(t *testing.T) {
|
|||||||
t.Fatalf("read maintained HTTP request example: %v", err)
|
t.Fatalf("read maintained HTTP request example: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
runner := &fakeRunner{result: &domain.RunResult{}}
|
runner := &fakeRunner{result: &scriptorium.RunResult{}}
|
||||||
h := NewHandler(runner)
|
h := NewHandler(runner)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewReader(body))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -68,38 +63,10 @@ func TestMaintainedHTTPRunExampleMatchesRequestContract(t *testing.T) {
|
|||||||
assertHTTPErrorCode(t, invalidW, http.StatusBadRequest, "invalid_json")
|
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{}
|
type handlerLLMClient struct{}
|
||||||
|
|
||||||
func (handlerLLMClient) Generate(ctx context.Context, req domain.GenerateRequest) (*domain.GenerateResponse, error) {
|
func (handlerLLMClient) Generate(ctx context.Context, req scriptorium.GenerateRequest) (*scriptorium.GenerateResponse, error) {
|
||||||
return &domain.GenerateResponse{Content: "ok"}, nil
|
return &scriptorium.GenerateResponse{Content: "ok"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
||||||
@@ -108,16 +75,16 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
|||||||
const envName = "SCRIPTORIUM_API_KEY"
|
const envName = "SCRIPTORIUM_API_KEY"
|
||||||
const secret = "never-include-me"
|
const secret = "never-include-me"
|
||||||
|
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
RunID: "11111111-1111-4111-8111-111111111111",
|
RunID: "11111111-1111-4111-8111-111111111111",
|
||||||
Artifact: domain.Artifact{
|
Artifact: scriptorium.Artifact{
|
||||||
Name: "output",
|
Name: "output",
|
||||||
ContentType: "text/plain",
|
ContentType: "text/plain",
|
||||||
Body: []byte("hello"),
|
Body: []byte("hello"),
|
||||||
Size: 5,
|
Size: 5,
|
||||||
Hash: "abc",
|
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",
|
PromptID: "prompt-1",
|
||||||
PromptVersion: "1.0.0",
|
PromptVersion: "1.0.0",
|
||||||
PromptHash: "phash",
|
PromptHash: "phash",
|
||||||
@@ -125,7 +92,7 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
|||||||
SelectedProfileID: "exec-default",
|
SelectedProfileID: "exec-default",
|
||||||
ModelName: "m1",
|
ModelName: "m1",
|
||||||
Endpoint: "http://llm/v1",
|
Endpoint: "http://llm/v1",
|
||||||
EffectiveModelParams: domain.ExecutionTarget{
|
EffectiveModelParams: scriptorium.ExecutionTarget{
|
||||||
Endpoint: "http://llm/v1",
|
Endpoint: "http://llm/v1",
|
||||||
Model: "m1",
|
Model: "m1",
|
||||||
Temperature: 0.2,
|
Temperature: 0.2,
|
||||||
@@ -136,7 +103,7 @@ func TestHandlerPostRunsSuccessWithExplicitProfileID(t *testing.T) {
|
|||||||
APIKeyEnv: envName,
|
APIKeyEnv: envName,
|
||||||
},
|
},
|
||||||
InputHashes: map[string]string{"transcript": "h1"},
|
InputHashes: map[string]string{"transcript": "h1"},
|
||||||
Usage: domain.TokenUsage{
|
Usage: scriptorium.TokenUsage{
|
||||||
PromptTokens: 1,
|
PromptTokens: 1,
|
||||||
CompletionTokens: 2,
|
CompletionTokens: 2,
|
||||||
TotalTokens: 3,
|
TotalTokens: 3,
|
||||||
@@ -325,13 +292,13 @@ func TestHandlerFileRefsOutsideArtifactRootAreRejected(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
|
func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
PromptID: "prompt-1",
|
PromptID: "prompt-1",
|
||||||
PromptVersion: "1.0.0",
|
PromptVersion: "1.0.0",
|
||||||
SelectedProfileID: "prompt-default",
|
SelectedProfileID: "prompt-default",
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
}}
|
}}
|
||||||
h := NewHandler(r)
|
h := NewHandler(r)
|
||||||
|
|
||||||
@@ -360,10 +327,10 @@ func TestHandlerPostRunsSuccessUsingPromptDefaultProfile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) {
|
func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
}}
|
}}
|
||||||
h := NewHandler(r)
|
h := NewHandler(r)
|
||||||
|
|
||||||
@@ -420,10 +387,10 @@ func TestHandlerModelOverrideMapsAllSupportedExecutionFields(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) {
|
func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
}}
|
}}
|
||||||
h := NewHandler(r)
|
h := NewHandler(r)
|
||||||
|
|
||||||
@@ -464,10 +431,10 @@ func TestHandlerModelOverrideAcceptsJSONCompatibleExtraParams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T) {
|
func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0},
|
||||||
}}
|
}}
|
||||||
h := NewHandler(r)
|
h := NewHandler(r)
|
||||||
|
|
||||||
@@ -491,10 +458,10 @@ func TestHandlerModelOverrideExplicitZeroTemperatureMapsAsPresent(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) {
|
func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.7},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1", Temperature: 0.7},
|
||||||
}}
|
}}
|
||||||
h := NewHandler(r)
|
h := NewHandler(r)
|
||||||
|
|
||||||
@@ -528,16 +495,16 @@ func TestHandlerModelOverrideOmittedTemperatureMapsAsAbsent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing.T) {
|
func TestHandlerResponseMetadataModelParamsIncludesAllSupportedFields(t *testing.T) {
|
||||||
r := &fakeRunner{result: &domain.RunResult{
|
r := &fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{
|
Artifact: scriptorium.Artifact{
|
||||||
Name: "output",
|
Name: "output",
|
||||||
ContentType: "text/plain",
|
ContentType: "text/plain",
|
||||||
Body: []byte("ok"),
|
Body: []byte("ok"),
|
||||||
Size: 2,
|
Size: 2,
|
||||||
Hash: "abc",
|
Hash: "abc",
|
||||||
},
|
},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{
|
EffectiveModelParams: scriptorium.ExecutionTarget{
|
||||||
Endpoint: "http://llm/v1",
|
Endpoint: "http://llm/v1",
|
||||||
Model: "gpt-test",
|
Model: "gpt-test",
|
||||||
Temperature: 0.4,
|
Temperature: 0.4,
|
||||||
@@ -657,10 +624,10 @@ func TestHandlerMalformedJSONBelowLimitStillBadRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerResponseTooLarge(t *testing.T) {
|
func TestHandlerResponseTooLarge(t *testing.T) {
|
||||||
h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{
|
h := NewHandlerWithOptions(&fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte(strings.Repeat("x", 128))},
|
Artifact: scriptorium.Artifact{Body: []byte(strings.Repeat("x", 128))},
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 64})
|
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 64})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{"prompt_id":"p","inputs":{"x":{"type":"file","uri":"a"}}}`))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -671,11 +638,11 @@ func TestHandlerResponseTooLarge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerRawOutputDoesNotBypassResponseLimit(t *testing.T) {
|
func TestHandlerRawOutputDoesNotBypassResponseLimit(t *testing.T) {
|
||||||
h := NewHandlerWithOptions(&fakeRunner{result: &domain.RunResult{
|
h := NewHandlerWithOptions(&fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("ok")},
|
Artifact: scriptorium.Artifact{Body: []byte("ok")},
|
||||||
RawOutput: strings.Repeat("raw", 80),
|
RawOutput: strings.Repeat("raw", 80),
|
||||||
Validation: domain.ValidationResult{Status: domain.ValidationPassed, Mode: domain.ValidationBasic, IsValid: true},
|
Validation: scriptorium.ValidationResult{Status: scriptorium.ValidationPassed, Mode: scriptorium.ValidationBasic, IsValid: true},
|
||||||
EffectiveModelParams: domain.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
EffectiveModelParams: scriptorium.ExecutionTarget{Endpoint: "http://llm/v1", Model: "m1"},
|
||||||
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 128})
|
}}, HandlerOptions{MaxRequestBytes: 1024, MaxResponseBytes: 128})
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
"prompt_id":"p",
|
"prompt_id":"p",
|
||||||
@@ -709,35 +676,12 @@ func TestHandlerMissingPromptID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerReservedExtraParamsThroughRunnerMapsToInvalidRequest(t *testing.T) {
|
func TestHandlerReservedExtraParamsThroughEngineMapsToInvalidRequest(t *testing.T) {
|
||||||
llmClient, err := llm.NewOpenAICompatibleClient(llm.OpenAICompatibleConfig{})
|
h := NewHandler(newHandlerEngineWithDefaultClient(t))
|
||||||
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)
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
req := httptest.NewRequest(http.MethodPost, "/v1/runs", bytes.NewBufferString(`{
|
||||||
"prompt_id":"p",
|
"prompt_id":"p",
|
||||||
"inputs":{"x":{"type":"file","uri":"a"}},
|
"inputs":{"x":{"type":"inline","body":"input"}},
|
||||||
"model":{"extra_params":{"model":"collision"}}
|
"model":{"extra_params":{"model":"collision"}}
|
||||||
}`))
|
}`))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -757,7 +701,7 @@ func TestHandlerReservedExtraParamsThroughRunnerMapsToInvalidRequest(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerUsecaseErrorMapping(t *testing.T) {
|
func TestHandlerPublicErrorMapping(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
err error
|
err error
|
||||||
@@ -766,18 +710,20 @@ func TestHandlerUsecaseErrorMapping(t *testing.T) {
|
|||||||
message string
|
message string
|
||||||
avoidCause 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 not found", err: scriptorium.ErrPromptNotFound, 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", 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: "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(scriptorium.ErrProfileRequired, scriptorium.ErrInvalidRequest), status: http.StatusBadRequest, code: "profile_required", message: "profile_id is required when prompt default_profile is not set"},
|
||||||
{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: scriptorium.ErrProfileNotFound, status: http.StatusNotFound, code: "profile_not_found", message: "execution profile not found"},
|
||||||
{name: "profile not found", err: wrap(usecase.ErrProfileLoad, profile.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: "profile invalid", err: wrap(usecase.ErrProfileLoad, profile.ErrInvalidProfile), status: http.StatusBadRequest, code: "profile_load_failed", message: "failed to load execution profile"},
|
{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: "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: "invalid request", err: scriptorium.ErrInvalidRequest, status: http.StatusBadRequest, code: "invalid_request", message: "invalid run request"},
|
||||||
{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: "file denied", err: ErrFileNotAllowed, status: http.StatusBadRequest, code: "artifact_not_allowed", message: "file input artifact is not allowed"},
|
||||||
{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: "file outside root", err: ErrFileOutsideRoot, status: http.StatusBadRequest, code: "artifact_not_allowed", message: "file input artifact is not allowed"},
|
||||||
{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: "file too large", err: ErrFileTooLarge, status: http.StatusRequestEntityTooLarge, code: "artifact_too_large", message: "file input artifact is too large"},
|
||||||
{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: "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: "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 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 {
|
for _, tc := range tests {
|
||||||
@@ -830,12 +776,12 @@ func TestHandlerRawAPIKeyRejectedByStrictJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) {
|
func TestHandlerValidationFailureStillSuccessAndRawOutputOptIn(t *testing.T) {
|
||||||
h := NewHandler(&fakeRunner{result: &domain.RunResult{
|
h := NewHandler(&fakeRunner{result: &scriptorium.RunResult{
|
||||||
Artifact: domain.Artifact{Body: []byte("bad json")},
|
Artifact: scriptorium.Artifact{Body: []byte("bad json")},
|
||||||
RawOutput: "bad json",
|
RawOutput: "bad json",
|
||||||
Validation: domain.ValidationResult{
|
Validation: scriptorium.ValidationResult{
|
||||||
Status: domain.ValidationFailed,
|
Status: scriptorium.ValidationFailed,
|
||||||
Mode: domain.ValidationJSON,
|
Mode: scriptorium.ValidationJSON,
|
||||||
Errors: []string{"invalid JSON"},
|
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 {
|
func newArtifactRootHandlerWithLimit(t *testing.T, root string, maxArtifactBytes int64) *Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
reader, err := artifact.NewRestrictedCompositeReaderWithLimit(root, maxArtifactBytes)
|
reader, err := NewRestrictedArtifactReader(root, maxArtifactBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected restricted artifact reader: %v", err)
|
t.Fatalf("expected restricted artifact reader: %v", err)
|
||||||
}
|
}
|
||||||
runner := usecase.NewRunner(
|
return NewHandler(newHandlerEngine(t, scriptorium.WithArtifactReader(reader)))
|
||||||
handlerPromptRepo{def: &domain.PromptDefinition{
|
}
|
||||||
ID: "p",
|
|
||||||
Version: "1",
|
func newHandlerEngine(t *testing.T, options ...scriptorium.Option) *scriptorium.Engine {
|
||||||
DefaultProfile: "exec",
|
t.Helper()
|
||||||
Templates: []domain.PromptMessageTemplate{{Role: "user", Content: "hi"}},
|
|
||||||
OutputFormat: domain.FormatText,
|
return newHandlerEngineWithOptions(t, append(options, scriptorium.WithLLMClient(handlerLLMClient{}))...)
|
||||||
Validation: domain.OutputContract{Format: domain.FormatText, ValidationMode: domain.ValidationNone},
|
}
|
||||||
}},
|
|
||||||
handlerProfileRepo{profile: &domain.ExecutionProfile{
|
func newHandlerEngineWithDefaultClient(t *testing.T, options ...scriptorium.Option) *scriptorium.Engine {
|
||||||
ID: "exec",
|
t.Helper()
|
||||||
Endpoint: "http://example.invalid/v1",
|
|
||||||
Model: "model",
|
return newHandlerEngineWithOptions(t, options...)
|
||||||
}},
|
}
|
||||||
reader,
|
|
||||||
handlerRenderer{},
|
func newHandlerEngineWithOptions(t *testing.T, options ...scriptorium.Option) *scriptorium.Engine {
|
||||||
handlerLLMClient{},
|
t.Helper()
|
||||||
nil,
|
|
||||||
)
|
promptDir := t.TempDir()
|
||||||
return NewHandler(runner)
|
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) {
|
func assertHTTPErrorCode(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
|
||||||
|
|||||||
@@ -5,22 +5,19 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
|
||||||
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
|
||||||
"io"
|
"io"
|
||||||
"mime"
|
"mime"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
ErrUnsupportedRefType = errors.New("unsupported artifact reference type")
|
||||||
ErrMissingInlineBody = errors.New("missing body for inline artifact")
|
ErrMissingInlineBody = errors.New("missing body for inline artifact")
|
||||||
ErrMissingFilePath = errors.New("missing file path for file 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.
|
// 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) {
|
func (c *CompositeReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.Artifact, error) {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -118,119 +94,17 @@ func (r *fileReader) Read(ctx context.Context, ref domain.ArtifactRef) (*domain.
|
|||||||
return readFileArtifact(ref.URI)
|
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) {
|
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)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
info, err := file.Stat()
|
data, err := io.ReadAll(file)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read file %s: %w", path, err)
|
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))
|
contentType := mime.TypeByExtension(filepath.Ext(path))
|
||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
|
|||||||
Reference in New Issue
Block a user