package httpadapter import ( "context" "encoding/json" "errors" "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", "invalid JSON request body") 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, TimeoutSeconds: req.Model.TimeoutSeconds, } } 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, message := mapRunError(err) writeError(w, status, code, message) 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: mapValidation(res.Validation), Metadata: metadataDTO{ RunID: res.RunID, ProfileID: res.ProfileID, ProfileVersion: res.ProfileVersion, ProfileHash: res.ProfileHash, ModelName: res.ModelName, Endpoint: res.Endpoint, ModelParams: modelParamsDTO{ Endpoint: res.ModelParams.Endpoint, Model: res.ModelParams.Model, Temperature: res.ModelParams.Temperature, MaxTokens: res.ModelParams.MaxTokens, TopP: res.ModelParams.TopP, TimeoutSeconds: res.ModelParams.TimeoutSeconds, }, InputHashes: res.InputHashes, PromptHash: res.PromptHash, Usage: tokenUsageDTO{ PromptTokens: res.Usage.PromptTokens, CompletionTokens: res.Usage.CompletionTokens, TotalTokens: res.Usage.TotalTokens, }, StartTime: res.StartTime, EndTime: res.EndTime, DurationMS: res.Duration.Milliseconds(), ValidationMode: string(res.Validation.Mode), ValidationStatus: string(res.Validation.Status), RepairAttemptsUsed: res.Validation.RepairAttempts, }, RawModelOutput: res.RawOutput, }) } func mapValidation(v domain.ValidationResult) validationDTO { return validationDTO{ Status: string(v.Status), Mode: string(v.Mode), Errors: v.Errors, SchemaPath: v.SchemaPath, RepairAttempts: v.RepairAttempts, IsValid: v.IsValid, } } func mapRunError(err error) (int, string, string) { switch { case errors.Is(err, profile.ErrProfileNotFound): return http.StatusNotFound, "profile_not_found", "profile not found" case errors.Is(err, usecase.ErrInvalidRequest): return http.StatusBadRequest, "invalid_request", "invalid run request" case errors.Is(err, usecase.ErrProfileLoad): return http.StatusBadRequest, "profile_load_failed", "failed to load profile" case errors.Is(err, usecase.ErrArtifactLoad): return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact" case errors.Is(err, usecase.ErrPromptRender): return http.StatusBadRequest, "prompt_render_failed", "failed to render prompt" case errors.Is(err, usecase.ErrLLMGenerate): return http.StatusBadGateway, "llm_failed", "model generation request failed" case errors.Is(err, usecase.ErrValidation): return http.StatusInternalServerError, "validation_runtime_failed", "validation runtime failed" default: return http.StatusInternalServerError, "internal_error", "internal server 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, }, }) }