diff --git a/docs/admin-api.openapi.json b/docs/admin-api.openapi.json index 4cfa23f..ad3417d 100644 --- a/docs/admin-api.openapi.json +++ b/docs/admin-api.openapi.json @@ -29,6 +29,9 @@ { "name": "capacity" }, + { + "name": "client-jobs" + }, { "name": "eval" }, @@ -280,6 +283,133 @@ } } }, + "/api/v1/client-jobs": { + "get": { + "operationId": "getClientJobs", + "summary": "Jobs clients asked for — 3D models — of every owner, newest first", + "tags": [ + "client-jobs" + ], + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Only jobs in this status: queued, running, cancelling, completed, failed, cancelled, expired.", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "The most jobs to answer with; 50 when absent.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobView" + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/client-jobs/{id}": { + "get": { + "operationId": "getClientJobsId", + "summary": "One job a client asked for", + "tags": [ + "client-jobs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job's ID, job_….", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/v1/client-jobs/{id}/cancel": { + "post": { + "operationId": "postClientJobsIdCancel", + "summary": "Stop a job, in the engine's queue too", + "tags": [ + "client-jobs" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job's ID, job_….", + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/Actor" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobView" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "default": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/api/v1/eval/runs": { "get": { "operationId": "getEvalRuns", @@ -6016,6 +6146,52 @@ } } }, + "JobView": { + "type": "object", + "description": "JobView is one job.", + "properties": { + "id": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "key_id": { + "type": "string" + }, + "endpoint": { + "type": "string" + }, + "model": { + "type": "string" + }, + "status": { + "type": "string" + }, + "progress": { + "description": "Any JSON value." + }, + "output_file_id": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "error": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "started_at": { + "type": "string" + }, + "finished_at": { + "type": "string" + } + } + }, "JudgeAskRequest": { "type": "object", "description": "JudgeAskRequest is a text to have a judge read.", diff --git a/internal/cli/ctl.go b/internal/cli/ctl.go index 37aac36..d0872d9 100644 --- a/internal/cli/ctl.go +++ b/internal/cli/ctl.go @@ -178,6 +178,7 @@ func newCtlCmd() *cobra.Command { newRetryCmd(client), newUpgradeCmd(client), newJobsCmd(client, &asJSON), + newMeshJobsCmd(client, &asJSON), newEngineCheckCmd(), newEngineBuildCmd(client), newToolCheckCmd(client), diff --git a/internal/cli/ctl_3d.go b/internal/cli/ctl_3d.go new file mode 100644 index 0000000..f353ad7 --- /dev/null +++ b/internal/cli/ctl_3d.go @@ -0,0 +1,130 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/url" + "time" + + "github.com/spf13/cobra" + + "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" +) + +// newMeshJobsCmd is the 3D models clients asked for, as jobs: every owner's, +// what each is doing, and a way to stop one. (ctl jobs is the control +// plane's own work, serving and updating models.) +func newMeshJobsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command { + cmd := &cobra.Command{ + Use: "3d", + Short: "3D models clients asked for: the jobs, how far along each is, and stopping one", + } + var status string + var limit int + list := &cobra.Command{ + Use: "list", + Short: "Every owner's 3D jobs, newest first", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + q := url.Values{} + if status != "" { + q.Set("status", status) + } + if limit > 0 { + q.Set("limit", fmt.Sprint(limit)) + } + var jobs []adminapi.JobView + if err := client().get(cmd.Context(), "/api/v1/client-jobs?"+q.Encode(), &jobs); err != nil { + return err + } + if *asJSON { + return emitJSON(jobs) + } + if len(jobs) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "no 3D jobs") + return nil + } + w := tabTo(cmd.OutOrStdout()) + fmt.Fprintln(w, "JOB\tMODEL\tSTATUS\tPROGRESS\tOWNER\tASKED") + for _, j := range jobs { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s ago\n", j.ID, j.Model, j.Status, progressText(j), + j.Owner, time.Since(j.CreatedAt).Round(time.Second)) + } + return w.Flush() + }, + } + list.Flags().StringVar(&status, "status", "", "only jobs in this status: queued, running, completed, failed…") + list.Flags().IntVar(&limit, "limit", 0, "the most jobs to show (50)") + get := &cobra.Command{ + Use: "get JOB", + Short: "One 3D job: its status, progress, result or error", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var j adminapi.JobView + if err := client().get(cmd.Context(), "/api/v1/client-jobs/"+url.PathEscape(args[0]), &j); err != nil { + return err + } + if *asJSON { + return emitJSON(j) + } + out := cmd.OutOrStdout() + fmt.Fprintf(out, "job %s\nmodel %s\nstatus %s\n", j.ID, j.Model, j.Status) + if p := progressText(j); p != "" { + fmt.Fprintf(out, "progress %s\n", p) + } + fmt.Fprintf(out, "owner %s (key %s)\nasked %s\n", j.Owner, j.KeyID, j.CreatedAt.UTC().Format(time.RFC3339)) + if j.FinishedAt != nil && j.StartedAt != nil { + fmt.Fprintf(out, "took %s\n", j.FinishedAt.Sub(*j.StartedAt).Round(time.Second)) + } + if j.OutputFileID != "" { + fmt.Fprintf(out, "result %s (%s)\n", j.OutputFileID, j.ContentType) + } + if j.Error != "" { + fmt.Fprintf(out, "error %s\n", j.Error) + } + return nil + }, + } + cancel := &cobra.Command{ + Use: "cancel JOB", + Short: "Stop a 3D job, in the engine's queue too", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var j adminapi.JobView + if err := client().post(cmd.Context(), "/api/v1/client-jobs/"+url.PathEscape(args[0])+"/cancel", nil, &j); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s is %s\n", j.ID, j.Status) + return nil + }, + } + cmd.AddCommand(list, get, cancel) + return cmd +} + +// progressText is a job's progress as a person reads it. +func progressText(j adminapi.JobView) string { + if len(j.Progress) == 0 { + return "" + } + var p struct { + State string `json:"state"` + Ahead int `json:"ahead"` + NodeClass string `json:"node_class"` + Step int `json:"step"` + Steps int `json:"steps"` + Percent int `json:"percent"` + } + if json.Unmarshal(j.Progress, &p) != nil { + return "" + } + switch { + case p.State == "queued" && p.Ahead > 0: + return fmt.Sprintf("queued, %d ahead", p.Ahead) + case p.State == "running" && p.Steps > 0: + return fmt.Sprintf("running %d%%, %s step %d/%d", p.Percent, p.NodeClass, p.Step, p.Steps) + case p.State == "running" && p.NodeClass != "": + return "running " + p.NodeClass + } + return p.State +} diff --git a/internal/control/adminapi/api.go b/internal/control/adminapi/api.go index 55d8d53..371b93f 100644 --- a/internal/control/adminapi/api.go +++ b/internal/control/adminapi/api.go @@ -203,6 +203,9 @@ func (a *API) Register(mux Mux) { mux.HandleFunc("POST /api/v1/models/{name}/origin", a.setOrigin) mux.HandleFunc("GET /api/v1/plan", a.planModel) mux.HandleFunc("POST /api/v1/metrics/range", a.metricsBatch) + mux.HandleFunc("GET /api/v1/client-jobs", a.listJobs) + mux.HandleFunc("GET /api/v1/client-jobs/{id}", a.getJob) + mux.HandleFunc("POST /api/v1/client-jobs/{id}/cancel", a.cancelJob) mux.HandleFunc("GET /api/v1/requests/{id}", a.request) mux.HandleFunc("GET /api/v1/requests/{id}/body", a.requestBody) mux.HandleFunc("POST /api/v1/requests/{id}/replay", a.replayRequest) diff --git a/internal/control/adminapi/jobs.go b/internal/control/adminapi/jobs.go new file mode 100644 index 0000000..0eeadc3 --- /dev/null +++ b/internal/control/adminapi/jobs.go @@ -0,0 +1,115 @@ +package adminapi + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// Jobs — 3D models asked for and asked after — as an operator sees them: +// every owner's, which a client sees only of its own. + +// jobStore is what jobs need of the store; asked of the usage source by +// type assertion, as requestFinder is. +type jobStore interface { + AllJobs(ctx context.Context, status string, limit int) ([]store.Job, error) + JobByID(ctx context.Context, id string) (store.Job, error) + CancelJob(ctx context.Context, id string, now time.Time) (store.Job, error) +} + +// JobView is one job. +type JobView struct { + ID string `json:"id"` + Owner string `json:"owner"` + KeyID string `json:"key_id"` + Endpoint string `json:"endpoint"` + Model string `json:"model"` + Status string `json:"status"` + Progress json.RawMessage `json:"progress,omitempty"` + OutputFileID string `json:"output_file_id,omitempty"` + ContentType string `json:"content_type,omitempty"` + Error string `json:"error,omitempty"` + CreatedAt time.Time `json:"created_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` +} + +func jobView(j store.Job) JobView { + v := JobView{ID: j.ID, Owner: j.Owner, KeyID: j.KeyID, Endpoint: j.Endpoint, Model: j.Model, Status: j.Status, + OutputFileID: j.OutputFileID, ContentType: j.ContentType, Error: j.Error, CreatedAt: j.CreatedAt} + if j.Progress != "" && json.Valid([]byte(j.Progress)) { + v.Progress = json.RawMessage(j.Progress) + } + if !j.StartedAt.IsZero() { + v.StartedAt = &j.StartedAt + } + if !j.FinishedAt.IsZero() { + v.FinishedAt = &j.FinishedAt + } + return v +} + +func (a *API) jobs() (jobStore, bool) { + s, ok := a.usage.(jobStore) + return s, ok +} + +func (a *API) listJobs(w http.ResponseWriter, r *http.Request) { + s, ok := a.jobs() + if !ok { + writeError(w, http.StatusNotImplemented, "jobs need the store, which this control plane runs without") + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + js, err := s.AllJobs(r.Context(), r.URL.Query().Get("status"), limit) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out := make([]JobView, 0, len(js)) + for _, j := range js { + out = append(out, jobView(j)) + } + writeJSON(w, http.StatusOK, out) +} + +func (a *API) getJob(w http.ResponseWriter, r *http.Request) { + s, ok := a.jobs() + if !ok { + writeError(w, http.StatusNotImplemented, "jobs need the store, which this control plane runs without") + return + } + j, err := s.JobByID(r.Context(), r.PathValue("id")) + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "no job "+r.PathValue("id")) + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, jobView(j)) +} + +func (a *API) cancelJob(w http.ResponseWriter, r *http.Request) { + s, ok := a.jobs() + if !ok { + writeError(w, http.StatusNotImplemented, "jobs need the store, which this control plane runs without") + return + } + if _, err := s.JobByID(r.Context(), r.PathValue("id")); errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "no job "+r.PathValue("id")) + return + } + j, err := s.CancelJob(r.Context(), r.PathValue("id"), time.Now()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, jobView(j)) +} diff --git a/internal/control/adminapi/routes.go b/internal/control/adminapi/routes.go index 591ec9c..7cc3d93 100644 --- a/internal/control/adminapi/routes.go +++ b/internal/control/adminapi/routes.go @@ -62,6 +62,7 @@ var PathParams = map[string]string{ "jobs/{id}": "The job's ID, as starting it answered.", "runs/{id}": "The quality run's ID, as starting it answered.", "requests/{id}": "The request's ID: the x-request-id a client was answered with.", + "client-jobs/{id}": "The job's ID, job_….", "fabrics/{id}": "The physical fabric's ID, as GET /api/v1/fabrics lists it.", "quotas/{scope}": "The scope: org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT, or their IDs after the colon.", "federation/{peer}": "The federation peer's name, as `llmgw ctl federation list` lists it.", @@ -397,6 +398,14 @@ var Routes = []Route{ {Name: "format", Description: "json for JSON; CSV otherwise."}, }, Response: []store.ExportRow{}, CSV: true}, + {Method: "GET", Path: "/api/v1/client-jobs", Summary: "Jobs clients asked for — 3D models — of every owner, newest first", + Query: []Param{ + {Name: "status", Description: "Only jobs in this status: queued, running, cancelling, completed, failed, cancelled, expired."}, + {Name: "limit", Type: "integer", Description: "The most jobs to answer with; 50 when absent."}, + }, + Response: []JobView{}}, + {Method: "GET", Path: "/api/v1/client-jobs/{id}", Summary: "One job a client asked for", Response: JobView{}}, + {Method: "POST", Path: "/api/v1/client-jobs/{id}/cancel", Summary: "Stop a job, in the engine's queue too", Response: JobView{}}, {Method: "GET", Path: "/api/v1/requests/{id}", Summary: "What one request went through", Response: RequestView{}}, {Method: "GET", Path: "/api/v1/requests/{id}/body", Summary: "A kept request and its answer", diff --git a/internal/router/chat.go b/internal/router/chat.go index 1a31b78..4e01637 100644 --- a/internal/router/chat.go +++ b/internal/router/chat.go @@ -57,6 +57,7 @@ func (r *Router) registerChat(mux *http.ServeMux, pages *web.AccountPages) { web.ReplyRenderer(r.cfg.MaxBodyBytes)(w, req) })) r.registerImagesPage(mux, pages) + r.registerMeshesPage(mux, pages) } // chatSend dispatches one turn on behalf of a signed-in person. @@ -257,12 +258,12 @@ func (r *Router) principalFor(req *http.Request, user store.User, asked string) // by name, "big-model" came first and every visitor who pressed Send without // touching the picker got placeholder text. func (r *Router) modelsFor(req *http.Request, user store.User) []web.ChatModel { - return r.modelsOf(req, user, false) + return r.modelsOf(req, user, func(m catalog.Model) bool { return !m.MakesImages() && !m.MakesMeshes() }) } // modelsOf is the models a person may use from a page: those that make // pictures, or those that write text. -func (r *Router) modelsOf(req *http.Request, user store.User, pictures bool) []web.ChatModel { +func (r *Router) modelsOf(req *http.Request, user store.User, want func(catalog.Model) bool) []web.ChatModel { principal, err := r.principalFor(req, user, req.URL.Query().Get("context")) if err != nil { return nil @@ -290,7 +291,7 @@ func (r *Router) modelsOf(req *http.Request, user store.User, pictures bool) []w continue } seen[name] = struct{}{} - if entry.MakesImages() == pictures && r.permits(principal, name) { + if want(entry) && r.permits(principal, name) { out = append(out, web.ChatModel{Name: name, Simulated: entry.Simulated(), Hardware: hardware[name]}) } } @@ -306,7 +307,7 @@ func (r *Router) modelsOf(req *http.Request, user store.User, pictures bool) []w continue } seen[name] = struct{}{} - if entry.MakesImages() == pictures && r.permits(principal, name) { + if want(entry) && r.permits(principal, name) { out = append(out, web.ChatModel{ Name: name, Simulated: entry.Simulated(), OnDemand: true, }) diff --git a/internal/router/handlers.go b/internal/router/handlers.go index f10c3bf..f7786d5 100644 --- a/internal/router/handlers.go +++ b/internal/router/handlers.go @@ -333,7 +333,7 @@ func (r *Router) Handler() http.Handler { } exempt := []string{"/health", "/ready", "/leader", "/", "/about", "/_static/", "/_lang/", "/claim/", "/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/", - "/admin", "/admin/", "/chat", "/chat/", "/images", "/images/", "/org", "/org/", "/setup/"} + "/admin", "/admin/", "/chat", "/chat/", "/images", "/images/", "/3d", "/3d/", "/org", "/org/", "/setup/"} // The docs are public unless the site said otherwise, and then guarded by // the session check in front of them — or, on a gateway with keys and no // accounts to sign in to, by the key like any other path. diff --git a/internal/router/images.go b/internal/router/images.go index eaafa74..c370152 100644 --- a/internal/router/images.go +++ b/internal/router/images.go @@ -15,6 +15,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/auth" "gitea.mynodes.xyz/ilker/llmgw/internal/comfy" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" "gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint" "gitea.mynodes.xyz/ilker/llmgw/internal/router/proxy" "gitea.mynodes.xyz/ilker/llmgw/internal/store" @@ -248,7 +249,7 @@ func (r *Router) registerImagesPage(mux *http.ServeMux, pages *web.AccountPages) if current == "" && !r.personalOff { current = personalContext } - web.Images(user, r.modelsOf(req, user, true), contexts, current, version.Version)(w, req) + web.Images(user, r.modelsOf(req, user, catalog.Model.MakesImages), contexts, current, version.Version)(w, req) })) mux.HandleFunc("POST /images/generate", pages.Guard(r.imagesSend)) } diff --git a/internal/router/images_test.go b/internal/router/images_test.go index 89bf4c3..599a679 100644 --- a/internal/router/images_test.go +++ b/internal/router/images_test.go @@ -19,6 +19,7 @@ import ( "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" "gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint" "gitea.mynodes.xyz/ilker/llmgw/internal/store" + "gitea.mynodes.xyz/ilker/llmgw/internal/web" ) // imageRouter has a ComfyUI model, flux, beside a text model, and returns @@ -244,7 +245,13 @@ func TestThePicturesPageOffersPictureModelsAndTheChatTheOthers(t *testing.T) { user := store.User{ID: "usr-1", ProjectID: project} names := func(pictures bool) []string { var out []string - for _, m := range rt.modelsOf(httptest.NewRequest("GET", "/", nil), user, pictures) { + var models []web.ChatModel + if pictures { + models = rt.modelsOf(httptest.NewRequest("GET", "/", nil), user, catalog.Model.MakesImages) + } else { + models = rt.modelsFor(httptest.NewRequest("GET", "/", nil), user) + } + for _, m := range models { out = append(out, m.Name) } return out diff --git a/internal/router/jobworker.go b/internal/router/jobworker.go index 040df3a..c454e0f 100644 --- a/internal/router/jobworker.go +++ b/internal/router/jobworker.go @@ -3,6 +3,7 @@ package router import ( "bytes" "context" + "errors" "fmt" "net/http" "os" @@ -124,9 +125,9 @@ func (r *Router) serveJob(ctx context.Context, j *store.Job) error { j.Status, j.Error, j.FinishedAt = store.JobFailed, msg, time.Now() return r.store.UpdateJob(ctx, *j) } - p, err := r.principalOfKey(ctx, j.KeyID, "") + p, err := r.principalOfJob(ctx, *j) if err != nil { - return fail("the key this job was made with can no longer be used: " + err.Error()) + return fail("the key or account this job was made with can no longer be used: " + err.Error()) } // A cancel asked for while it runs ends the request, which takes the @@ -227,3 +228,33 @@ func extensionFor(contentType string) string { } return ".bin" } + +// principalOfJob is who a job acts for, as they stand now: the key it was +// made with, or — for one made from the portal — the person whose session +// made it, while they may still use the gateway and the project the job is +// charged to. +func (r *Router) principalOfJob(ctx context.Context, j store.Job) (auth.Principal, error) { + uid, session := strings.CutPrefix(j.KeyID, "session:") + if !session { + return r.principalOfKey(ctx, j.KeyID, "") + } + u, err := r.store.UserByID(ctx, uid) + if err != nil { + return auth.Principal{}, err + } + if u.State != store.UserActive { + return auth.Principal{}, fmt.Errorf("the account that made it is %s", u.State) + } + project, _ := strings.CutPrefix(j.Owner, "project:") + if !strings.HasPrefix(j.Owner, "project:") { + project = "" + } else if ok, err := r.store.CanUseProject(ctx, uid, project); err != nil || !ok { + return auth.Principal{}, errors.New("its account is no longer a member of the project it is charged to") + } + eff, err := r.store.ResolveFor(ctx, j.KeyID, uid, project) + if err != nil { + return auth.Principal{}, err + } + return auth.Principal{KeyID: j.KeyID, KeyPrefix: "session", UserID: uid, ProjectID: eff.ProjectID, + TeamID: eff.TeamID, OrgID: eff.OrgID, Band: eff.Band, RoutingProfile: eff.RoutingProfile, Limits: eff}, nil +} diff --git a/internal/router/meshes.go b/internal/router/meshes.go index 1bdcea9..a1e9f13 100644 --- a/internal/router/meshes.go +++ b/internal/router/meshes.go @@ -10,15 +10,20 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "strconv" "strings" "time" + "gitea.mynodes.xyz/ilker/llmgw/internal/auth" "gitea.mynodes.xyz/ilker/llmgw/internal/comfy" "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" "gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint" "gitea.mynodes.xyz/ilker/llmgw/internal/router/proxy" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" + "gitea.mynodes.xyz/ilker/llmgw/internal/version" + "gitea.mynodes.xyz/ilker/llmgw/internal/web" ) // 3D models from a picture, made by ComfyUI. Clients ask for them as a job @@ -268,3 +273,111 @@ func (r *Router) jobProgress(ctx context.Context, req *parsedRequest) func(comfy } } } + +// registerMeshesPage mounts the page that makes 3D models, beside the +// pictures page and for the same reasons: a session, never a key, and the +// request sent down the path an API client's takes — here, a job. +func (r *Router) registerMeshesPage(mux *http.ServeMux, pages *web.AccountPages) { + pages.SetMeshes(func() bool { + for _, m := range r.modelMap() { + if m.MakesMeshes() { + return true + } + } + return false + }) + mux.HandleFunc("GET /3d", pages.Guard(func(w http.ResponseWriter, req *http.Request, user store.User) { + contexts, _ := r.chatContexts(req.Context(), user) + current, err := r.pickContext(req.Context(), user, req.URL.Query().Get("context")) + if err != nil { + current = "" + } + if current == "" && !r.personalOff { + current = personalContext + } + web.Meshes(user, r.modelsOf(req, user, catalog.Model.MakesMeshes), contexts, current, version.Version)(w, req) + })) + mux.HandleFunc("POST /3d/generate", pages.Guard(r.meshesSend)) + mux.HandleFunc("GET /3d/jobs/{id}", pages.Guard(func(w http.ResponseWriter, req *http.Request, user store.User) { + if next, ok := r.asPerson(w, req, user, req.URL.Query().Get("context")); ok { + r.handleGetJob(w, next) + } + })) + // The mesh itself, from where the page is, for its viewer to load. + mux.HandleFunc("GET /3d/jobs/{id}/file", pages.Guard(func(w http.ResponseWriter, req *http.Request, user store.User) { + next, ok := r.asPerson(w, req, user, req.URL.Query().Get("context")) + if !ok { + return + } + p, _ := auth.FromContext(next.Context()) + j, ok := r.ownedJob(w, next, ownerOf(p)) + if !ok { + return + } + if j.OutputFileID == "" { + http.Error(w, "the job has made nothing yet", http.StatusNotFound) + return + } + b, err := r.store.FileContent(req.Context(), j.OutputFileID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", j.ContentType) + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", j.ID+".glb")) + _, _ = w.Write(b) + })) +} + +// asPerson is a page's request as the signed-in person's, in the place they +// chose: what the API handlers read a key's principal from. +func (r *Router) asPerson(w http.ResponseWriter, req *http.Request, user store.User, context string) (*http.Request, bool) { + p, err := r.principalFor(req, user, context) + if err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return nil, false + } + return req.WithContext(auth.WithPrincipal(req.Context(), p)), true +} + +// meshesSend asks for a 3D model on behalf of a signed-in person. +func (r *Router) meshesSend(w http.ResponseWriter, req *http.Request, user store.User) { + body, err := io.ReadAll(io.LimitReader(req.Body, r.cfg.MaxBodyBytes)) + if err != nil { + http.Error(w, "could not read the request", http.StatusBadRequest) + return + } + var in struct { + Model string `json:"model"` + Image string `json:"image"` + Seed *int64 `json:"seed"` + Resolution int `json:"resolution"` + Context string `json:"context"` + } + if err := json.Unmarshal(body, &in); err != nil || in.Model == "" { + http.Error(w, "a model and a picture are required", http.StatusBadRequest) + return + } + // Rebuilt from the fields the page has, so it can set nothing else. + out := map[string]any{"model": in.Model, "image": in.Image} + if in.Seed != nil { + out["seed"] = *in.Seed + } + if in.Resolution > 0 { + out["resolution"] = in.Resolution + } + upstream, err := json.Marshal(out) + if err != nil { + http.Error(w, "could not build the request", http.StatusInternalServerError) + return + } + next, ok := r.asPerson(w, req, user, in.Context) + if !ok { + return + } + next = next.Clone(next.Context()) + next.Body = io.NopCloser(bytes.NewReader(upstream)) + next.ContentLength = int64(len(upstream)) + next.Header.Set("Content-Type", "application/json") + r.handleCreateJob(w, next) +} diff --git a/internal/router/meshes_test.go b/internal/router/meshes_test.go index 61fdd4a..3e2e904 100644 --- a/internal/router/meshes_test.go +++ b/internal/router/meshes_test.go @@ -18,6 +18,7 @@ import ( "testing" "time" + "gitea.mynodes.xyz/ilker/llmgw/internal/auth" "gitea.mynodes.xyz/ilker/llmgw/internal/comfy" "gitea.mynodes.xyz/ilker/llmgw/internal/config" "gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog" @@ -287,3 +288,73 @@ func TestAnOBJLeavesTheGatewayAsAGLB(t *testing.T) { } } } + +func TestThe3DPageMakesAJobAndShowsItsMeshToItsOwnerOnly(t *testing.T) { + c := newMeshComfy(t) + rt, _ := meshRouter(t, c) + project := mustProjectOf(t, rt, "key-m") + user := store.User{ID: "usr-1", Email: "ann@studio.example", State: store.UserActive, CreatedAt: time.Now()} + if err := rt.store.CreateUser(t.Context(), user); err != nil { + t.Fatal(err) + } + if err := rt.store.AddMembership(t.Context(), user.ID, store.ScopeProject, project, "member", "test", time.Now()); err != nil { + t.Fatal(err) + } + org, _ := rt.store.CreateOrg(t.Context(), "other") + team, _ := rt.store.CreateTeam(t.Context(), org, "t") + otherProject, _ := rt.store.CreateProject(t.Context(), team, "p") + stranger := store.User{ID: "usr-2", Email: "bob@else.example", State: store.UserActive, CreatedAt: time.Now()} + _ = rt.store.CreateUser(t.Context(), stranger) + _ = rt.store.AddMembership(t.Context(), stranger.ID, store.ScopeProject, otherProject, "member", "test", time.Now()) + + body := `{"model":"trellis2","image":"` + testPicture(t) + `","resolution":256,"seed":5}` + rec := httptest.NewRecorder() + rt.meshesSend(rec, httptest.NewRequest("POST", "/3d/generate", strings.NewReader(body)), user) + if rec.Code != http.StatusAccepted { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + var j map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &j) + id := j["id"].(string) + rt.wakeJobs() + + get := func(u store.User, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest("GET", path, nil) + req.SetPathValue("id", id) + rec := httptest.NewRecorder() + next, ok := rt.asPerson(rec, req, u, "") + if !ok { + return rec + } + if strings.HasSuffix(path, "/file") { + p, _ := auth.FromContext(next.Context()) + jj, ok := rt.ownedJob(rec, next, ownerOf(p)) + if ok { + b, _ := rt.store.FileContent(next.Context(), jj.OutputFileID) + rec.Header().Set("Content-Type", jj.ContentType) + _, _ = rec.Write(b) + } + return rec + } + rt.handleGetJob(rec, next) + return rec + } + deadline := time.Now().Add(10 * time.Second) + for { + rec := get(user, "/3d/jobs/"+id) + _ = json.Unmarshal(rec.Body.Bytes(), &j) + if j["status"] == "completed" { + break + } + if time.Now().After(deadline) { + t.Fatalf("not made: %s", rec.Body.String()) + } + time.Sleep(20 * time.Millisecond) + } + if rec := get(user, "/3d/jobs/"+id+"/file"); !strings.HasPrefix(rec.Body.String(), "glTF") { + t.Errorf("the mesh: %q", rec.Body.String()[:min(20, rec.Body.Len())]) + } + if rec := get(stranger, "/3d/jobs/"+id); rec.Code != http.StatusNotFound { + t.Errorf("another project's person read the job: %d", rec.Code) + } +} diff --git a/internal/router/pages_auth_test.go b/internal/router/pages_auth_test.go index 7423157..62cbcec 100644 --- a/internal/router/pages_auth_test.go +++ b/internal/router/pages_auth_test.go @@ -17,7 +17,7 @@ func TestSessionPagesAreNotAskedForAnAPIKey(t *testing.T) { h := rt.Handler() // The /admin pages are mounted only with a cluster view, which this // router has none of; these are the session pages it does mount. - for _, path := range []string{"/profile", "/chat", "/images", "/org", "/org/org-x"} { + for _, path := range []string{"/profile", "/chat", "/images", "/3d", "/org", "/org/org-x"} { w := httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) if strings.Contains(w.Body.String(), "api key") { diff --git a/internal/store/jobs.go b/internal/store/jobs.go index 4a820ac..18c3cc1 100644 --- a/internal/store/jobs.go +++ b/internal/store/jobs.go @@ -161,6 +161,36 @@ func (s *DB) Jobs(ctx context.Context, owner, after string, limit int) ([]Job, e return out, rows.Err() } +// AllJobs is every owner's jobs, newest first, those in one status when it +// is named: what an operator looks over. +func (s *DB) AllJobs(ctx context.Context, status string, limit int) ([]Job, error) { + q := `SELECT ` + jobCols + ` FROM jobs` + var args []any + if status != "" { + q += ` WHERE status = ?` + args = append(args, status) + } + if limit <= 0 || limit > 500 { + limit = 50 + } + q += ` ORDER BY created_at DESC LIMIT ?` + args = append(args, limit) + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Job + for rows.Next() { + j, err := scanJob(rows) + if err != nil { + return nil, err + } + out = append(out, j) + } + return out, rows.Err() +} + // ClaimJob takes the lease on the oldest job with work left that no gateway // holds, for owner until the time given. ok is false when there is none. func (s *DB) ClaimJob(ctx context.Context, owner string, now, until time.Time) (Job, bool, error) { diff --git a/internal/store/jobs_test.go b/internal/store/jobs_test.go index 09acb99..9ffe531 100644 --- a/internal/store/jobs_test.go +++ b/internal/store/jobs_test.go @@ -1,6 +1,7 @@ package store import ( + "fmt" "testing" "time" ) @@ -95,3 +96,27 @@ func checkJobs(t *testing.T, db *DB) { t.Errorf("deleted %d", n) } } + +func TestAnOperatorSeesEveryOwnersJobs(t *testing.T) { + db := capacityStore(t) + now := time.Now() + for i, owner := range []string{"project:a", "project:b", "user:c"} { + status := JobQueued + if i == 2 { + status = JobCompleted + } + if err := db.PutJob(t.Context(), Job{ID: fmt.Sprint("job_", i), Owner: owner, KeyID: "k", Endpoint: "/v1/3d/generations", + Model: "m", Input: []byte("{}"), Status: status, CreatedAt: now.Add(time.Duration(i) * time.Second), + ExpiresAt: now.Add(time.Hour)}); err != nil { + t.Fatal(err) + } + } + all, err := db.AllJobs(t.Context(), "", 0) + if err != nil || len(all) != 3 || all[0].ID != "job_2" { + t.Fatalf("%v %v: every owner's, newest first", all, err) + } + queued, _ := db.AllJobs(t.Context(), JobQueued, 0) + if len(queued) != 2 { + t.Errorf("%d queued", len(queued)) + } +} diff --git a/internal/web/account.go b/internal/web/account.go index 6a26a41..85c24e6 100644 --- a/internal/web/account.go +++ b/internal/web/account.go @@ -96,6 +96,7 @@ type AccountPages struct { // images says whether some model makes pictures; nil when the // pictures page is not mounted. images func() bool + meshes func() bool guardLibrary func() guard.Config // guardFor names the guardrail policies in force for someone, and // whether they keep nothing; nil when there are none. diff --git a/internal/web/csp.go b/internal/web/csp.go index b0c4ff2..6b4e226 100644 --- a/internal/web/csp.go +++ b/internal/web/csp.go @@ -40,7 +40,9 @@ func contentSecurityPolicy(page []byte) string { "script-src 'self'" + prefixSpace(strings.Join(hashes, " ")) + "; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + - "img-src 'self' data: https:; " + // model cards show their images + // Model cards show their images; a mesh's viewer hands its textures + // to the browser as blob: URLs. + "img-src 'self' data: blob: https:; " + "connect-src 'self'; " + "object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" } diff --git a/internal/web/images.go b/internal/web/images.go index 84fe22b..aeadac6 100644 --- a/internal/web/images.go +++ b/internal/web/images.go @@ -35,3 +35,19 @@ func Images(user store.User, models []ChatModel, contexts []ChatContext, current // SetImages has the menu offer the pictures page while some model makes // them. Asked on every page, so it must be cheap: the catalog, not a request. func (p *AccountPages) SetImages(available func() bool) { p.images = available } + +// Meshes renders the page that makes 3D models from a picture. +func Meshes(user store.User, models []ChatModel, contexts []ChatContext, current, version string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + noStore(w) + render(w, r, "meshes", imagesData{ + Nav: navFrom(r, "meshes"), + Contexts: contexts, Context: current, + User: user, Models: models, CSRF: csrfFor(r), + Version: version, Now: time.Now().UTC().Format(time.RFC3339), + }) + } +} + +// SetMeshes has the menu offer the 3D page while some model makes meshes. +func (p *AccountPages) SetMeshes(available func() bool) { p.meshes = available } diff --git a/internal/web/images_test.go b/internal/web/images_test.go index fca0558..69d7fa0 100644 --- a/internal/web/images_test.go +++ b/internal/web/images_test.go @@ -34,3 +34,28 @@ func TestThePicturesPageDrawsInEveryLanguage(t *testing.T) { t.Error("no models, and the page does not say so") } } + +func TestThe3DPageDrawsInEveryLanguageWithItsViewer(t *testing.T) { + for _, lang := range []string{"en", "tr"} { + req := httptest.NewRequest("GET", "/3d", nil) + req.Header.Set("Accept-Language", lang) + rec := httptest.NewRecorder() + Meshes(store.User{Email: "a@b.c"}, []ChatModel{{Name: "triposr"}}, + []ChatContext{{Value: "personal", Label: "Personal"}}, "personal", "v0")(rec, req) + page := rec.Body.String() + if rec.Code != 200 { + t.Fatalf("%s: %d %s", lang, rec.Code, page) + } + for _, want := range []string{`