package prediction import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "path" "strings" "sync" "time" "github.com/rose_cat707/luminary/internal/model" "github.com/rose_cat707/luminary/internal/provider/comfyui" "github.com/rose_cat707/luminary/internal/storage/imagestore" "github.com/rose_cat707/luminary/internal/store/cache" "gorm.io/gorm" ) type Service struct { db *gorm.DB cache *cache.Store images *imagestore.Store hub *Hub publicURL string waitSec int64 mu sync.Mutex running map[string]context.CancelFunc } func NewService(db *gorm.DB, c *cache.Store, images *imagestore.Store, publicURL string, waitSec int64) *Service { if waitSec <= 0 { waitSec = 60 } return &Service{ db: db, cache: c, images: images, hub: NewHub(), publicURL: strings.TrimRight(publicURL, "/"), waitSec: waitSec, running: make(map[string]context.CancelFunc), } } func (s *Service) Hub() *Hub { return s.hub } func NewPredictionID() (string, error) { b := make([]byte, 12) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } type CreateRequest struct { Version string `json:"version"` Input map[string]any `json:"input"` } func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req CreateRequest, clientIP string, wait bool) (*model.Prediction, error) { if req.Version == "" { return nil, fmt.Errorf("version is required") } entry, provider, err := s.resolveModel(req.Version, ingress) if err != nil { return nil, err } if !provider.Enabled { return nil, fmt.Errorf("provider disabled") } input, err := ValidateInput(entry.WorkflowType, req.Input) if err != nil { return nil, err } binding, err := ParseBinding(entry.InputBindingJSON) if err != nil { return nil, err } var workflow map[string]any _ = json.Unmarshal([]byte(entry.WorkflowJSON), &workflow) if err := binding.Validate(workflow, RequiredBindings(entry.WorkflowType)); err != nil { return nil, fmt.Errorf("model binding invalid: %w", err) } id, err := NewPredictionID() if err != nil { return nil, err } inputJSON, _ := json.Marshal(input) p := model.Prediction{ ID: id, IngressKeyID: ingress.ID, ProviderID: provider.ID, ModelEntryID: entry.ID, Version: req.Version, Model: entry.ModelID, InputJSON: string(inputJSON), Status: model.PredictionStarting, ClientID: "luminary-" + id, CreatedAt: time.Now(), } if err := s.db.Create(&p).Error; err != nil { return nil, err } runCtx, cancel := context.WithCancel(context.Background()) s.mu.Lock() s.running[id] = cancel s.mu.Unlock() go s.execute(runCtx, &p, entry, &provider, binding, input, clientIP) if wait { deadline := time.After(time.Duration(s.waitSec) * time.Second) for { select { case <-deadline: return s.Get(ctx, id, ingress) case <-time.After(200 * time.Millisecond): cur, err := s.Get(ctx, id, ingress) if err != nil { return nil, err } if cur.Status == model.PredictionSucceeded || cur.Status == model.PredictionFailed || cur.Status == model.PredictionCanceled { return cur, nil } } } } return s.toAPI(&p), nil } func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model.ModelEntry, model.Provider, error) { allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON) allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON) var candidates []model.ModelEntry for _, p := range s.cache.ListProviders() { if p.Category != model.CategoryImage || !p.Enabled { continue } if !cache.MatchesList(allowedProviders, p.Name) && !cache.MatchesList(allowedProviders, string(p.Type)) && !cache.MatchesList(allowedProviders, "*") { continue } for _, m := range s.cache.GetModels(p.ID) { if !m.Enabled { continue } ingressID := m.IngressID() if !m.MatchesIngressName(version) { continue } if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, "*") { continue } candidates = append(candidates, m) } } if len(candidates) == 0 { return model.ModelEntry{}, model.Provider{}, fmt.Errorf("no model found for version %s", version) } m := candidates[0] p, ok := s.cache.GetProvider(m.ProviderID) if !ok { return model.ModelEntry{}, model.Provider{}, fmt.Errorf("provider not found") } return m, p, nil } func (s *Service) execute(ctx context.Context, p *model.Prediction, entry model.ModelEntry, provider *model.Provider, binding InputBinding, input map[string]any, clientIP string) { defer func() { s.mu.Lock() delete(s.running, p.ID) s.mu.Unlock() }() start := time.Now() s.updateStatus(p, model.PredictionProcessing, "", nil) s.hub.Publish(p.ID, Event{Status: string(model.PredictionProcessing)}) client := comfyui.New(provider.BaseURL) runInput := cloneMap(input) if entry.WorkflowType == model.WorkflowImg2Img { imageURL, _ := runInput["image"].(string) data, err := downloadURL(ctx, imageURL) if err != nil { s.fail(p, clientIP, start, fmt.Errorf("download image: %w", err)) return } name := path.Base(imageURL) if name == "" || name == "." || name == "/" { name = "input.png" } uploaded, err := client.UploadImage(ctx, data, name) if err != nil { s.fail(p, clientIP, start, fmt.Errorf("upload image: %w", err)) return } runInput["image"] = uploaded } prompt, err := BuildPrompt(entry.WorkflowJSON, binding, runInput) if err != nil { s.fail(p, clientIP, start, err) return } wsCtx, wsCancel := context.WithCancel(ctx) defer wsCancel() go client.Watch(wsCtx, p.ClientID, func(ev comfyui.ProgressEvent) { switch ev.Type { case "progress": if ev.Max > 0 { logs := fmt.Sprintf("progress %.0f/%.0f", ev.Value, ev.Max) s.appendLogs(p, logs) s.hub.Publish(p.ID, Event{Status: string(model.PredictionProcessing), Logs: logs}) } case "execution_error": s.appendLogs(p, ev.ErrorText) } }) submit, err := client.SubmitPrompt(ctx, prompt, p.ClientID) if err != nil { s.fail(p, clientIP, start, err) return } p.ComfyPromptID = submit.PromptID now := time.Now() p.StartedAt = &now s.db.Model(p).Updates(map[string]any{"comfy_prompt_id": p.ComfyPromptID, "started_at": p.StartedAt}) for { select { case <-ctx.Done(): _ = client.Interrupt(context.Background()) s.updateStatus(p, model.PredictionCanceled, "canceled", nil) s.hub.Publish(p.ID, Event{Status: string(model.PredictionCanceled), Done: true}) s.recordUsage(ingressByID(s.cache, p.IngressKeyID), provider, clientIP, p, start, "canceled", "canceled") return case <-time.After(2 * time.Second): history, err := client.GetHistory(ctx, p.ComfyPromptID) if err != nil { continue } if _, ok := history[p.ComfyPromptID]; !ok { continue } images := comfyui.CollectOutputImages(history, p.ComfyPromptID) if len(images) == 0 { continue } var urls []string for _, img := range images { data, mime, err := client.DownloadView(ctx, img.Filename, img.Subfolder, img.Type) if err != nil { s.fail(p, clientIP, start, err) return } stored, err := s.images.Save(p.ID, img.Filename, data, mime) if err != nil { s.fail(p, clientIP, start, err) return } urls = append(urls, s.images.SignURL(stored.ID)) } outJSON, _ := json.Marshal(urls) p.OutputJSON = string(outJSON) completed := time.Now() p.CompletedAt = &completed metrics, _ := json.Marshal(map[string]any{ "predict_time": time.Since(start).Seconds(), "total_time": completed.Sub(p.CreatedAt).Seconds(), }) p.MetricsJSON = string(metrics) s.updateStatus(p, model.PredictionSucceeded, "", &completed) s.hub.Publish(p.ID, Event{ Status: string(model.PredictionSucceeded), Output: urls, Metrics: map[string]any{"predict_time": time.Since(start).Seconds()}, Done: true, }) s.recordUsage(ingressByID(s.cache, p.IngressKeyID), provider, clientIP, p, start, "success", "") return } } } func (s *Service) fail(p *model.Prediction, clientIP string, start time.Time, err error) { completed := time.Now() p.CompletedAt = &completed p.Error = err.Error() s.updateStatus(p, model.PredictionFailed, err.Error(), &completed) s.hub.Publish(p.ID, Event{Status: string(model.PredictionFailed), Error: err.Error(), Done: true}) prov, _ := s.cache.GetProvider(p.ProviderID) s.recordUsage(ingressByID(s.cache, p.IngressKeyID), &prov, clientIP, p, start, "error", err.Error()) } func (s *Service) updateStatus(p *model.Prediction, status model.PredictionStatus, errMsg string, completed *time.Time) { p.Status = status if errMsg != "" { p.Error = errMsg } updates := map[string]any{"status": status, "error": p.Error, "logs": p.Logs, "output_json": p.OutputJSON, "metrics_json": p.MetricsJSON} if completed != nil { updates["completed_at"] = completed } s.db.Model(p).Where("id = ?", p.ID).Updates(updates) } func (s *Service) appendLogs(p *model.Prediction, line string) { if p.Logs != "" { p.Logs += "\n" } p.Logs += line s.db.Model(p).Where("id = ?", p.ID).Update("logs", p.Logs) } func (s *Service) Get(ctx context.Context, id string, ingress *model.IngressKey) (*model.Prediction, error) { var p model.Prediction if err := s.db.First(&p, "id = ?", id).Error; err != nil { return nil, fmt.Errorf("prediction not found") } if ingress != nil && p.IngressKeyID != ingress.ID { return nil, fmt.Errorf("prediction not found") } return s.toAPI(&p), nil } func (s *Service) List(ctx context.Context, ingress *model.IngressKey, cursor string, limit int) ([]*model.Prediction, error) { if limit <= 0 || limit > 100 { limit = 100 } q := s.db.Where("ingress_key_id = ?", ingress.ID).Order("created_at desc").Limit(limit) if cursor != "" { q = q.Where("id < ?", cursor) } var rows []model.Prediction if err := q.Find(&rows).Error; err != nil { return nil, err } out := make([]*model.Prediction, 0, len(rows)) for i := range rows { out = append(out, s.toAPI(&rows[i])) } return out, nil } func (s *Service) Cancel(ctx context.Context, id string, ingress *model.IngressKey) (*model.Prediction, error) { p, err := s.Get(ctx, id, ingress) if err != nil { return nil, err } if p.Status == model.PredictionSucceeded || p.Status == model.PredictionFailed || p.Status == model.PredictionCanceled { return p, nil } s.mu.Lock() if cancel, ok := s.running[id]; ok { cancel() } s.mu.Unlock() return s.Get(ctx, id, ingress) } func (s *Service) toAPI(p *model.Prediction) *model.Prediction { return p } func (s *Service) APIView(p *model.Prediction, baseURL string) map[string]any { var input any _ = json.Unmarshal([]byte(p.InputJSON), &input) var output any if p.OutputJSON != "" { _ = json.Unmarshal([]byte(p.OutputJSON), &output) } var metrics any if p.MetricsJSON != "" { _ = json.Unmarshal([]byte(p.MetricsJSON), &metrics) } base := strings.TrimRight(baseURL, "/") return map[string]any{ "id": p.ID, "version": p.Version, "input": input, "output": output, "error": nilIfEmpty(p.Error), "status": p.Status, "created_at": p.CreatedAt.UTC().Format(time.RFC3339Nano), "started_at": formatTime(p.StartedAt), "completed_at": formatTime(p.CompletedAt), "logs": p.Logs, "metrics": metrics, "urls": map[string]string{ "get": fmt.Sprintf("%s/v1/predictions/%s", base, p.ID), "cancel": fmt.Sprintf("%s/v1/predictions/%s/cancel", base, p.ID), "stream": fmt.Sprintf("%s/v1/predictions/%s/stream", base, p.ID), }, } } func nilIfEmpty(s string) any { if s == "" { return nil } return s } func formatTime(t *time.Time) any { if t == nil { return nil } return t.UTC().Format(time.RFC3339Nano) } func downloadURL(ctx context.Context, raw string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { return nil, fmt.Errorf("http %d", resp.StatusCode) } return io.ReadAll(resp.Body) } func cloneMap(in map[string]any) map[string]any { b, _ := json.Marshal(in) var out map[string]any _ = json.Unmarshal(b, &out) return out } func ingressByID(c *cache.Store, id uint) *model.IngressKey { for _, k := range c.ListIngressKeys() { if k.ID == id { kCopy := k return &kCopy } } return nil } func (s *Service) recordUsage(ingress *model.IngressKey, provider *model.Provider, clientIP string, p *model.Prediction, start time.Time, status, errMsg string) { if ingress == nil { return } var providerID uint if provider != nil { providerID = provider.ID } s.cache.RecordUsage(cache.UsageDelta{ IngressKeyID: ingress.ID, ProviderID: providerID, ClientIP: clientIP, Endpoint: model.EndpointPrediction, Model: p.ID, LatencyMs: time.Since(start).Milliseconds(), Status: status, ErrorMsg: errMsg, RequestBody: p.InputJSON, ResponseBody: p.OutputJSON, Requests: 1, }) } func (s *Service) GetDBPrediction(id string) (*model.Prediction, error) { var p model.Prediction if err := s.db.First(&p, "id = ?", id).Error; err != nil { return nil, err } return &p, nil }