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/promptdef" "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 dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", "invalid JSON request body") return } if strings.TrimSpace(req.PromptID) == "" { writeError(w, http.StatusBadRequest, "invalid_request", "prompt_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.ExecutionTarget if req.Model != nil { model = executionTargetFromModelOverrideDTO(req.Model) } res, err := h.runner.Run(r.Context(), domain.RunRequest{ PromptID: req.PromptID, PromptVersion: req.PromptVersion, ProfileID: req.ProfileID, Inputs: mappedInputs, Vars: req.Vars, Execution: model, }) if err != nil { status, code, message := mapRunError(err) writeError(w, status, code, message) return } resp := 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, PromptID: res.PromptID, PromptVersion: res.PromptVersion, PromptHash: res.PromptHash, RenderedPromptHash: res.RenderedPromptHash, SelectedProfileID: res.SelectedProfileID, ModelName: res.ModelName, Endpoint: res.Endpoint, ModelParams: modelParamsDTOFromExecutionTarget(res.EffectiveModelParams), InputHashes: res.InputHashes, 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, }, } if req.IncludeRawOutput { raw := res.RawOutput resp.RawModelOutput = &raw } writeJSON(w, http.StatusOK, resp) } func executionTargetFromModelOverrideDTO(dto *modelOverrideRequestDTO) *domain.ExecutionTarget { if dto == nil { return nil } return &domain.ExecutionTarget{ Endpoint: dto.Endpoint, Model: dto.Model, Temperature: dto.Temperature, MaxTokens: dto.MaxTokens, TopP: dto.TopP, TimeoutSeconds: dto.TimeoutSeconds, ServiceTier: dto.ServiceTier, ReasoningEffort: dto.ReasoningEffort, APIKeyEnv: dto.APIKeyEnv, ExtraParams: dto.ExtraParams, } } func modelParamsDTOFromExecutionTarget(target domain.ExecutionTarget) modelParamsDTO { return modelParamsDTO{ Endpoint: target.Endpoint, Model: target.Model, Temperature: target.Temperature, MaxTokens: target.MaxTokens, TopP: target.TopP, TimeoutSeconds: target.TimeoutSeconds, ServiceTier: target.ServiceTier, ReasoningEffort: target.ReasoningEffort, APIKeyEnv: target.APIKeyEnv, ExtraParams: target.ExtraParams, } } 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, promptdef.ErrPromptDefinitionNotFound): return http.StatusNotFound, "prompt_not_found", "prompt definition not found" case errors.Is(err, profile.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): 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" case errors.Is(err, usecase.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.ErrProfileLoad): return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition" 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, }, }) }