200 lines
6.8 KiB
Go
200 lines
6.8 KiB
Go
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 = &domain.ExecutionTarget{
|
|
Endpoint: req.Model.Endpoint,
|
|
Model: req.Model.Model,
|
|
Temperature: req.Model.Temperature,
|
|
MaxTokens: req.Model.MaxTokens,
|
|
TopP: req.Model.TopP,
|
|
TimeoutSeconds: req.Model.TimeoutSeconds,
|
|
ReasoningEffort: req.Model.ReasoningEffort,
|
|
APIKeyEnv: req.Model.APIKeyEnv,
|
|
ExtraParams: req.Model.ExtraParams,
|
|
}
|
|
}
|
|
|
|
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: modelParamsDTO{
|
|
Endpoint: res.EffectiveModelParams.Endpoint,
|
|
Model: res.EffectiveModelParams.Model,
|
|
Temperature: res.EffectiveModelParams.Temperature,
|
|
MaxTokens: res.EffectiveModelParams.MaxTokens,
|
|
TopP: res.EffectiveModelParams.TopP,
|
|
TimeoutSeconds: res.EffectiveModelParams.TimeoutSeconds,
|
|
ReasoningEffort: res.EffectiveModelParams.ReasoningEffort,
|
|
APIKeyEnv: res.EffectiveModelParams.APIKeyEnv,
|
|
ExtraParams: res.EffectiveModelParams.ExtraParams,
|
|
},
|
|
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 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.ErrInvalidRequest) && strings.Contains(err.Error(), "profile id is required either in request or prompt default_profile"):
|
|
return http.StatusBadRequest, "profile_required", "profile_id is required when prompt default_profile is not set"
|
|
case errors.Is(err, usecase.ErrInvalidRequest) && strings.Contains(err.Error(), "api key environment variable"):
|
|
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,
|
|
},
|
|
})
|
|
}
|