package httpadapter import ( "context" "encoding/json" "errors" "io" "net/http" "strings" "gitea.maximumdirect.net/eric/promptkit" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" ) type Runner interface { Run(ctx context.Context, req promptkit.RunRequest) (*promptkit.RunResult, error) } type Handler struct { runner Runner options HandlerOptions } type HandlerOptions struct { MaxRequestBytes int64 MaxResponseBytes int64 } func NewHandler(runner Runner) *Handler { return NewHandlerWithOptions(runner, HandlerOptions{ MaxRequestBytes: defaults.HTTPMaxRequestBytesDefault, MaxResponseBytes: defaults.HTTPMaxResponseBytesDefault, }) } func NewHandlerWithOptions(runner Runner, options HandlerOptions) *Handler { return &Handler{runner: runner, options: options} } 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 body := r.Body if h.options.MaxRequestBytes > 0 { body = http.MaxBytesReader(w, r.Body, h.options.MaxRequestBytes) } dec := json.NewDecoder(body) dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { if isRequestTooLarge(err) { writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "request body is too large") return } writeError(w, http.StatusBadRequest, "invalid_json", "invalid JSON request body") return } var trailing any if err := dec.Decode(&trailing); err != io.EOF { if isRequestTooLarge(err) { writeError(w, http.StatusRequestEntityTooLarge, "request_too_large", "request body is too large") return } 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]promptkit.ArtifactRef, len(req.Inputs)) for name, in := range req.Inputs { mappedInputs[name] = promptkit.ArtifactRef{ Type: promptkit.ArtifactRefType(in.Type), URI: in.URI, Body: in.Body, } } var model *promptkit.ExecutionTargetOverride if req.Model != nil { model = executionTargetOverrideFromModelOverrideDTO(req.Model) } res, err := h.runner.Run(r.Context(), promptkit.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, CachedTokens: res.Usage.CachedTokens, CacheWriteTokens: res.Usage.CacheWriteTokens, }, 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 } writeLimitedJSON(w, http.StatusOK, resp, h.options.MaxResponseBytes) } func executionTargetOverrideFromModelOverrideDTO(dto *modelOverrideRequestDTO) *promptkit.ExecutionTargetOverride { if dto == nil { return nil } return &promptkit.ExecutionTargetOverride{ 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 promptkit.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 promptkit.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, promptkit.ErrPromptNotFound): return http.StatusNotFound, "prompt_not_found", "prompt definition not found" case errors.Is(err, promptkit.ErrProfileNotFound): return http.StatusNotFound, "profile_not_found", "execution profile not found" case errors.Is(err, promptkit.ErrProfileRequired): return http.StatusBadRequest, "profile_required", "profile_id is required when prompt default_profile is not set" case errors.Is(err, promptkit.ErrAPIKeyEnvMissing): return http.StatusBadRequest, "api_key_env_missing", "api_key_env is set but the environment variable is missing" case errors.Is(err, promptkit.ErrPromptLoad): return http.StatusBadRequest, "prompt_load_failed", "failed to load prompt definition" case errors.Is(err, promptkit.ErrProfileLoad): return http.StatusBadRequest, "profile_load_failed", "failed to load execution profile" case errors.Is(err, promptkit.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, ErrFileTooLarge): return http.StatusRequestEntityTooLarge, "artifact_too_large", "file input artifact is too large" case errors.Is(err, promptkit.ErrArtifactLoad): return http.StatusBadRequest, "artifact_read_failed", "failed to read input artifact" case errors.Is(err, promptkit.ErrPromptRender): return http.StatusBadRequest, "prompt_render_failed", "failed to render prompt" case errors.Is(err, promptkit.ErrLLMGenerate): return http.StatusBadGateway, "llm_failed", "model generation request failed" case errors.Is(err, promptkit.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) { writeLimitedJSON(w, status, v, 0) } func writeLimitedJSON(w http.ResponseWriter, status int, v any, maxBytes int64) { data, err := json.Marshal(v) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "internal server error") return } data = append(data, '\n') if maxBytes > 0 && int64(len(data)) > maxBytes { writeError(w, http.StatusRequestEntityTooLarge, "response_too_large", "response body is too large") return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _, _ = w.Write(data) } func writeError(w http.ResponseWriter, status int, code, message string) { writeJSON(w, status, errorResponse{ Error: errorBody{ Code: code, Message: message, }, }) } func isRequestTooLarge(err error) bool { var maxBytesErr *http.MaxBytesError return errors.As(err, &maxBytesErr) }