Split ingress keys into text/image protocols with Replicate model listing.
CI / docker (push) Successful in 3m38s

Add model input defaults (including prompt), workflow model editing, ingress API docs, and fix img2img image upload filenames for signed URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-29 21:41:06 +08:00
parent 2665ac1dee
commit 321683f863
35 changed files with 1646 additions and 167 deletions
+32 -14
View File
@@ -19,18 +19,27 @@ func (h *Handler) ListIngressKeys(c *gin.Context) {
func (h *Handler) CreateIngressKey(c *gin.Context) {
var req struct {
Name string `json:"name"`
BudgetLimit float64 `json:"budget_limit"`
RateLimitRPM int `json:"rate_limit_rpm"`
RateLimitTPM int `json:"rate_limit_tpm"`
AllowedProviders []string `json:"allowed_providers"`
AllowedModels []string `json:"allowed_models"`
ExpiresAt *time.Time `json:"expires_at"`
Name string `json:"name"`
Protocol model.IngressKeyProtocol `json:"protocol"`
BudgetLimit float64 `json:"budget_limit"`
RateLimitRPM int `json:"rate_limit_rpm"`
RateLimitTPM int `json:"rate_limit_tpm"`
AllowedProviders []string `json:"allowed_providers"`
AllowedModels []string `json:"allowed_models"`
ExpiresAt *time.Time `json:"expires_at"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
protocol := req.Protocol
if protocol == "" {
protocol = model.IngressProtocolText
}
if !protocol.Valid() {
c.JSON(http.StatusBadRequest, gin.H{"error": "protocol must be text or image"})
return
}
plainKey, err := generateIngressKey()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"})
@@ -57,6 +66,7 @@ func (h *Handler) CreateIngressKey(c *gin.Context) {
Name: req.Name,
KeyHash: hash,
Prefix: plainKey[:12] + "...",
Protocol: protocol,
Enabled: true,
BudgetLimit: req.BudgetLimit,
RateLimitRPM: req.RateLimitRPM,
@@ -91,18 +101,26 @@ func (h *Handler) UpdateIngressKey(c *gin.Context) {
return
}
var req struct {
Name string `json:"name"`
Enabled *bool `json:"enabled"`
BudgetLimit *float64 `json:"budget_limit"`
RateLimitRPM *int `json:"rate_limit_rpm"`
RateLimitTPM *int `json:"rate_limit_tpm"`
AllowedProviders []string `json:"allowed_providers"`
AllowedModels []string `json:"allowed_models"`
Name string `json:"name"`
Protocol *model.IngressKeyProtocol `json:"protocol"`
Enabled *bool `json:"enabled"`
BudgetLimit *float64 `json:"budget_limit"`
RateLimitRPM *int `json:"rate_limit_rpm"`
RateLimitTPM *int `json:"rate_limit_tpm"`
AllowedProviders []string `json:"allowed_providers"`
AllowedModels []string `json:"allowed_models"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
if req.Protocol != nil {
if !req.Protocol.Valid() {
c.JSON(http.StatusBadRequest, gin.H{"error": "protocol must be text or image"})
return
}
k.Protocol = *req.Protocol
}
if req.Name != "" {
k.Name = req.Name
}
+47 -12
View File
@@ -227,8 +227,10 @@ func (h *Handler) CreateModel(c *gin.Context) {
DisplayName string `json:"display_name"`
WorkflowType model.WorkflowType `json:"workflow_type"`
WorkflowJSON string `json:"workflow_json"`
InputBindingJSON string `json:"input_binding_json"`
InputBinding map[string]*prediction.NodeField `json:"input_binding"`
InputBindingJSON string `json:"input_binding_json"`
InputBinding map[string]*prediction.NodeField `json:"input_binding"`
InputDefaultsJSON string `json:"input_defaults_json"`
InputDefaults map[string]any `json:"input_defaults"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.ModelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
@@ -260,19 +262,25 @@ func (h *Handler) CreateModel(c *gin.Context) {
b, _ := json.Marshal(req.InputBinding)
bindingJSON = string(b)
}
defaultsJSON, err := marshalInputDefaults(req.InputDefaultsJSON, req.InputDefaults)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
wt := req.WorkflowType
if wt == "" {
wt = model.WorkflowTxt2Img
}
m := model.ModelEntry{
ProviderID: providerID,
ModelID: req.ModelID,
Alias: normalizeAlias(req.Alias),
DisplayName: req.DisplayName,
WorkflowType: wt,
WorkflowJSON: req.WorkflowJSON,
InputBindingJSON: bindingJSON,
VersionHash: prediction.HashWorkflowJSON(req.WorkflowJSON),
ProviderID: providerID,
ModelID: req.ModelID,
Alias: normalizeAlias(req.Alias),
DisplayName: req.DisplayName,
WorkflowType: wt,
WorkflowJSON: req.WorkflowJSON,
InputBindingJSON: bindingJSON,
InputDefaultsJSON: defaultsJSON,
VersionHash: prediction.HashWorkflowJSON(req.WorkflowJSON),
Enabled: true,
}
if prov.Category == model.CategoryImage {
@@ -301,8 +309,10 @@ func (h *Handler) UpdateModel(c *gin.Context) {
ModelID string `json:"model_id"`
WorkflowType model.WorkflowType `json:"workflow_type"`
WorkflowJSON string `json:"workflow_json"`
InputBindingJSON string `json:"input_binding_json"`
InputBinding map[string]*prediction.NodeField `json:"input_binding"`
InputBindingJSON string `json:"input_binding_json"`
InputBinding map[string]*prediction.NodeField `json:"input_binding"`
InputDefaultsJSON string `json:"input_defaults_json"`
InputDefaults map[string]any `json:"input_defaults"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
@@ -349,6 +359,14 @@ func (h *Handler) UpdateModel(c *gin.Context) {
b, _ := json.Marshal(req.InputBinding)
m.InputBindingJSON = string(b)
}
if req.InputDefaults != nil || req.InputDefaultsJSON != "" {
defaultsJSON, err := marshalInputDefaults(req.InputDefaultsJSON, req.InputDefaults)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
m.InputDefaultsJSON = defaultsJSON
}
prov, _ := h.cache.GetProvider(m.ProviderID)
if prov.Category == model.CategoryImage {
m.Alias = ""
@@ -422,3 +440,20 @@ func (h *Handler) DeleteModel(c *gin.Context) {
h.cache.SetModels(m.ProviderID, filtered)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func marshalInputDefaults(raw string, defaults map[string]any) (string, error) {
if defaults != nil {
b, err := json.Marshal(defaults)
if err != nil {
return "", err
}
return string(b), nil
}
if raw == "" {
return "{}", nil
}
if _, err := prediction.ParseInputDefaults(raw); err != nil {
return "", err
}
return raw, nil
}
-15
View File
@@ -21,7 +21,6 @@ func New(gw *gateway.Service) *Handler {
}
func (h *Handler) Register(r *gin.RouterGroup) {
r.GET("/models", h.ListModels)
r.POST("/chat/completions", h.forward(provider.EndpointChatCompletion))
r.POST("/completions", h.forward(provider.EndpointTextCompletion))
r.POST("/responses", h.forward(provider.EndpointResponses))
@@ -38,20 +37,6 @@ func (h *Handler) ingressKey(c *gin.Context) (*model.IngressKey, bool) {
return k, ok
}
func (h *Handler) ListModels(c *gin.Context) {
ingress, ok := h.ingressKey(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
models, err := h.gateway.ListModels(c.Request.Context(), ingress)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"object": "list", "data": models})
}
func (h *Handler) forward(endpoint provider.EndpointType) gin.HandlerFunc {
return func(c *gin.Context) {
ingress, ok := h.ingressKey(c)
+57
View File
@@ -0,0 +1,57 @@
package v1handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/gateway"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/prediction"
)
type ModelsHandler struct {
gateway *gateway.Service
predictions *prediction.Service
baseURL func() string
}
func NewModelsHandler(gw *gateway.Service, pred *prediction.Service, baseURL func() string) *ModelsHandler {
return &ModelsHandler{gateway: gw, predictions: pred, baseURL: baseURL}
}
func (h *ModelsHandler) List(c *gin.Context) {
ingress, ok := ingressKey(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
switch ingress.EffectiveProtocol() {
case model.IngressProtocolImage:
results, err := h.predictions.ListPublicModels(c.Request.Context(), ingress, h.baseURL())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"previous": nil,
"next": nil,
"results": results,
})
default:
models, err := h.gateway.ListModels(c.Request.Context(), ingress)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"object": "list", "data": models})
}
}
func ingressKey(c *gin.Context) (*model.IngressKey, bool) {
v, ok := c.Get("ingress_key")
if !ok {
return nil, false
}
k, ok := v.(*model.IngressKey)
return k, ok
}
+38
View File
@@ -159,6 +159,44 @@ type IngressKeyResolver interface {
ResolveIngressKey(key string) (*model.IngressKey, error)
}
// RequireIngressProtocol restricts routes to ingress keys of the given protocol family.
func RequireIngressProtocol(want model.IngressKeyProtocol) gin.HandlerFunc {
return func(ctx *gin.Context) {
v, ok := ctx.Get("ingress_key")
if !ok {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing api key"})
return
}
k, ok := v.(*model.IngressKey)
if !ok {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid api key"})
return
}
if k.EffectiveProtocol() != want {
abortWrongProtocol(ctx, want, protocolMismatchMessage(k.EffectiveProtocol(), want))
return
}
ctx.Next()
}
}
func protocolMismatchMessage(got, want model.IngressKeyProtocol) string {
if want == model.IngressProtocolImage {
return "this api key is for text (OpenAI) API only; use an image (Replicate) ingress key"
}
return "this api key is for image (Replicate) API only; use a text (OpenAI) ingress key"
}
func abortWrongProtocol(ctx *gin.Context, want model.IngressKeyProtocol, msg string) {
if want == model.IngressProtocolImage {
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"detail": msg})
return
}
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": gin.H{"message": msg, "type": "forbidden"},
})
}
func Logger() gin.HandlerFunc {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
return param.TimeStamp.Format(time.RFC3339) + " " + param.Method + " " + param.Path + " " + param.ClientIP + "\n"
+7 -5
View File
@@ -82,17 +82,19 @@ type ModelEntry struct {
WorkflowType WorkflowType `gorm:"default:txt2img" json:"workflow_type"`
WorkflowJSON string `gorm:"type:text" json:"workflow_json"`
InputBindingJSON string `gorm:"type:text" json:"input_binding_json"`
InputDefaultsJSON string `gorm:"type:text;default:'{}'" json:"input_defaults_json"`
VersionHash string `gorm:"index" json:"version_hash"`
Enabled bool `gorm:"default:true" json:"enabled"`
CreatedAt time.Time `json:"created_at"`
}
type IngressKey struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
Prefix string `gorm:"not null" json:"prefix"`
Enabled bool `gorm:"default:true" json:"enabled"`
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null" json:"name"`
KeyHash string `gorm:"uniqueIndex;not null" json:"-"`
Prefix string `gorm:"not null" json:"prefix"`
Protocol IngressKeyProtocol `gorm:"not null;default:text" json:"protocol"`
Enabled bool `gorm:"default:true" json:"enabled"`
BudgetLimit float64 `gorm:"default:0" json:"budget_limit"`
BudgetUsed float64 `gorm:"default:0" json:"budget_used"`
RateLimitRPM int `gorm:"default:0" json:"rate_limit_rpm"`
+21
View File
@@ -0,0 +1,21 @@
package model
// IngressKeyProtocol distinguishes OpenAI-compatible text APIs from Replicate-compatible image APIs.
type IngressKeyProtocol string
const (
IngressProtocolText IngressKeyProtocol = "text"
IngressProtocolImage IngressKeyProtocol = "image"
)
func (p IngressKeyProtocol) Valid() bool {
return p == IngressProtocolText || p == IngressProtocolImage
}
// EffectiveProtocol returns text for legacy rows without an explicit protocol.
func (k IngressKey) EffectiveProtocol() IngressKeyProtocol {
if k.Protocol == "" {
return IngressProtocolText
}
return k.Protocol
}
+22
View File
@@ -0,0 +1,22 @@
package model
import "testing"
func TestIngressKeyEffectiveProtocol(t *testing.T) {
if (IngressKey{}).EffectiveProtocol() != IngressProtocolText {
t.Fatal("empty protocol should default to text")
}
k := IngressKey{Protocol: IngressProtocolImage}
if k.EffectiveProtocol() != IngressProtocolImage {
t.Fatal("expected image protocol")
}
}
func TestIngressKeyProtocolValid(t *testing.T) {
if !IngressProtocolText.Valid() || !IngressProtocolImage.Valid() {
t.Fatal("text and image should be valid")
}
if IngressKeyProtocol("other").Valid() {
t.Fatal("unexpected valid protocol")
}
}
+38
View File
@@ -1,5 +1,7 @@
package model
import "strings"
// IngressID returns the model name exposed to ingress API clients.
func (m ModelEntry) IngressID() string {
if m.Alias != "" {
@@ -8,6 +10,27 @@ func (m ModelEntry) IngressID() string {
return m.ModelID
}
// SplitIngressOwnerName splits an ingress model identifier into Replicate owner/name parts.
func SplitIngressOwnerName(ingressID, providerName string) (owner, name string) {
if i := strings.Index(ingressID, "/"); i > 0 {
return ingressID[:i], ingressID[i+1:]
}
owner = providerName
if owner == "" {
owner = "luminary"
}
name = ingressID
if name == "" {
name = "model"
}
return owner, name
}
// OwnerName returns the Replicate-style owner/name for an ingress model entry.
func (m ModelEntry) OwnerName(providerName string) (string, string) {
return SplitIngressOwnerName(m.IngressID(), providerName)
}
// MatchesIngressName reports whether the entry matches an ingress model identifier.
func (m ModelEntry) MatchesIngressName(name string) bool {
if name == "" || !m.Enabled {
@@ -18,3 +41,18 @@ func (m ModelEntry) MatchesIngressName(name string) bool {
}
return m.Alias != "" && m.Alias == name
}
// MatchesIngressRef reports whether ref identifies this model (ingress ID, owner/name, or short name).
func (m ModelEntry) MatchesIngressRef(ref, providerName string) bool {
if !m.Enabled || ref == "" {
return false
}
if m.MatchesIngressName(ref) {
return true
}
owner, name := m.OwnerName(providerName)
if ref == name || ref == owner+"/"+name {
return true
}
return false
}
+32
View File
@@ -0,0 +1,32 @@
package model
import "testing"
func TestSplitIngressOwnerName(t *testing.T) {
owner, name := SplitIngressOwnerName("luminary/flux-sdxl", "comfy-local")
if owner != "luminary" || name != "flux-sdxl" {
t.Fatalf("got %q/%q", owner, name)
}
owner, name = SplitIngressOwnerName("plain-model", "comfy-local")
if owner != "comfy-local" || name != "plain-model" {
t.Fatalf("got %q/%q", owner, name)
}
}
func TestMatchesIngressRef(t *testing.T) {
entry := ModelEntry{
Enabled: true,
Alias: "luminary/flux-txt2img",
ModelID: "internal-flux",
}
provider := "comfy-ui"
if !entry.MatchesIngressRef("luminary/flux-txt2img", provider) {
t.Fatal("expected ingress id / owner/name match")
}
if !entry.MatchesIngressRef("flux-txt2img", provider) {
t.Fatal("expected short name match")
}
if entry.MatchesIngressRef("other/flux-txt2img", provider) {
t.Fatal("unexpected owner mismatch match")
}
}
+62
View File
@@ -0,0 +1,62 @@
package prediction
import (
"encoding/json"
"fmt"
"github.com/rose_cat707/luminary/internal/model"
)
func ParseInputDefaults(raw string) (map[string]any, error) {
if raw == "" || raw == "{}" {
return map[string]any{}, nil
}
var out map[string]any
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil, err
}
if out == nil {
return map[string]any{}, nil
}
return out, nil
}
func effectiveDefault(d ParamDef, overrides map[string]any) (any, bool) {
if overrides != nil {
if v, ok := overrides[d.Name]; ok {
return v, true
}
}
if d.Default != nil {
return d.Default, true
}
return nil, false
}
func ValidateInputWithDefaults(wt model.WorkflowType, input map[string]any, overrides map[string]any) (map[string]any, error) {
defs := ParamsForWorkflowType(wt)
out := make(map[string]any, len(defs))
for _, d := range defs {
v, ok := input[d.Name]
if !ok || v == nil {
if def, has := effectiveDefault(d, overrides); has {
coerced, err := coerceValue(d, def)
if err != nil {
return nil, fmt.Errorf("%s: %w", d.Name, err)
}
out[d.Name] = coerced
continue
}
if d.Required {
return nil, fmt.Errorf("missing required field: %s", d.Name)
}
continue
}
coerced, err := coerceValue(d, v)
if err != nil {
return nil, fmt.Errorf("%s: %w", d.Name, err)
}
out[d.Name] = coerced
}
return out, nil
}
+55
View File
@@ -0,0 +1,55 @@
package prediction
import (
"testing"
"github.com/rose_cat707/luminary/internal/model"
)
func TestValidateInputWithDefaultsOverride(t *testing.T) {
overrides := map[string]any{"width": float64(768), "steps": float64(30)}
out, err := ValidateInputWithDefaults(model.WorkflowTxt2Img, map[string]any{
"prompt": "hello",
}, overrides)
if err != nil {
t.Fatal(err)
}
if out["width"] != 768 && out["width"] != float64(768) {
t.Fatalf("width got %#v", out["width"])
}
if out["steps"] != 30 && out["steps"] != float64(30) {
t.Fatalf("steps got %#v", out["steps"])
}
}
func TestOpenAPISchemaUsesModelDefaults(t *testing.T) {
schema := OpenAPISchemaForWorkflow(model.WorkflowTxt2Img, "flux", "desc", map[string]any{
"width": float64(768),
})
components := schema["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
width := props["width"].(map[string]any)
if width["default"] != float64(768) {
t.Fatalf("unexpected width default %#v", width["default"])
}
}
func TestValidateInputPromptDefault(t *testing.T) {
overrides := map[string]any{"prompt": "a scenic landscape"}
out, err := ValidateInputWithDefaults(model.WorkflowTxt2Img, map[string]any{}, overrides)
if err != nil {
t.Fatal(err)
}
if out["prompt"] != "a scenic landscape" {
t.Fatalf("prompt got %#v", out["prompt"])
}
}
func TestValidateInputRequiredWithoutDefault(t *testing.T) {
_, err := ValidateInputWithDefaults(model.WorkflowTxt2Img, map[string]any{}, map[string]any{})
if err == nil {
t.Fatal("expected missing prompt error")
}
}
+133
View File
@@ -0,0 +1,133 @@
package prediction
import (
"bytes"
"fmt"
"net/url"
"path"
"strings"
)
func imageFilenameFromURL(raw string, data []byte) string {
name := ""
if u, err := url.Parse(raw); err == nil {
if base := path.Base(u.Path); base != "" && base != "." && base != "/" {
name = base
}
}
if name == "" {
name = "input"
}
name = sanitizeUploadFilename(name)
if path.Ext(name) == "" {
name += sniffImageExt(data)
}
return name
}
func imageFilenameFromStored(filename, mime string, data []byte) string {
name := filename
if name == "" {
name = "input"
}
name = sanitizeUploadFilename(name)
if path.Ext(name) == "" {
if ext := extFromMime(mime); ext != "" {
name += ext
} else {
name += sniffImageExt(data)
}
}
return name
}
func sanitizeUploadFilename(name string) string {
name = path.Base(name)
replacer := strings.NewReplacer(
"?", "_",
"&", "_",
"=", "_",
"#", "_",
"%", "_",
"\\", "_",
":", "_",
"*", "_",
"\"", "_",
"<", "_",
">", "_",
"|", "_",
)
name = replacer.Replace(name)
name = strings.TrimSpace(name)
if name == "" || name == "." {
return "input"
}
return name
}
func sniffImageExt(data []byte) string {
switch {
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
return ".jpg"
case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}):
return ".png"
case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
return ".webp"
case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
return ".gif"
default:
return ".png"
}
}
func extFromMime(mime string) string {
switch strings.ToLower(strings.TrimSpace(mime)) {
case "image/jpeg", "image/jpg":
return ".jpg"
case "image/png":
return ".png"
case "image/webp":
return ".webp"
case "image/gif":
return ".gif"
default:
return ""
}
}
func parseGatewayFileID(raw string) (string, bool) {
u, err := url.Parse(raw)
if err != nil {
return "", false
}
segments := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(segments) == 2 && segments[0] == "files" && segments[1] != "" {
return segments[1], true
}
return "", false
}
func validateImageBytes(data []byte) error {
if len(data) == 0 {
return fmt.Errorf("empty image payload")
}
if !looksLikeImage(data) {
return fmt.Errorf("unrecognized image format")
}
return nil
}
func looksLikeImage(data []byte) bool {
switch {
case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
return true
case len(data) >= 8 && bytes.Equal(data[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}):
return true
case len(data) >= 12 && bytes.Equal(data[:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")):
return true
case len(data) >= 6 && (bytes.Equal(data[:6], []byte("GIF87a")) || bytes.Equal(data[:6], []byte("GIF89a"))):
return true
default:
return false
}
}
+36
View File
@@ -0,0 +1,36 @@
package prediction
import "testing"
func TestImageFilenameFromURLStripsQuery(t *testing.T) {
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
got := imageFilenameFromURL("https://gateway.example.com/files/abc-123?exp=1&sig=deadbeef", png)
if got != "abc-123.png" {
t.Fatalf("got %q", got)
}
}
func TestImageFilenameFromURLAddsExtension(t *testing.T) {
jpg := []byte{0xFF, 0xD8, 0xFF, 0x00}
got := imageFilenameFromURL("https://example.com/uploads/photo", jpg)
if got != "photo.jpg" {
t.Fatalf("got %q", got)
}
}
func TestParseGatewayFileID(t *testing.T) {
id, ok := parseGatewayFileID("http://localhost:8293/files/img-uuid?exp=1&sig=x")
if !ok || id != "img-uuid" {
t.Fatalf("got id=%q ok=%v", id, ok)
}
}
func TestValidateImageBytes(t *testing.T) {
if err := validateImageBytes(nil); err == nil {
t.Fatal("expected empty payload error")
}
png := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
if err := validateImageBytes(png); err != nil {
t.Fatal(err)
}
}
+75
View File
@@ -0,0 +1,75 @@
package prediction
import (
"context"
"fmt"
"strings"
"time"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/store/cache"
)
func splitOwnerName(ingressID, providerName string) (owner, name string) {
return model.SplitIngressOwnerName(ingressID, providerName)
}
func modelDescription(m model.ModelEntry) string {
if m.DisplayName != "" {
return m.DisplayName
}
switch m.WorkflowType {
case model.WorkflowImg2Img:
return "Image-to-image workflow"
default:
return "Text-to-image workflow"
}
}
// ListPublicModels returns Replicate-compatible model objects visible to the ingress key.
func (s *Service) ListPublicModels(ctx context.Context, ingress *model.IngressKey, baseURL string) ([]map[string]any, error) {
_ = ctx
allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON)
allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON)
baseURL = strings.TrimRight(baseURL, "/")
var results []map[string]any
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 || m.VersionHash == "" {
continue
}
ingressID := m.IngressID()
if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, m.VersionHash) && !cache.MatchesList(allowedModels, "*") {
continue
}
owner, name := splitOwnerName(ingressID, p.Name)
desc := modelDescription(m)
defaults, _ := ParseInputDefaults(m.InputDefaultsJSON)
createdAt := m.CreatedAt
if createdAt.IsZero() {
createdAt = time.Now()
}
results = append(results, map[string]any{
"url": fmt.Sprintf("%s/v1/models/%s/%s", baseURL, owner, name),
"owner": owner,
"name": name,
"description": desc,
"visibility": "public",
"latest_version": map[string]any{
"id": ingressID,
"created_at": createdAt.UTC().Format(time.RFC3339),
"cog_version": "",
"openapi_schema": OpenAPISchemaForWorkflow(m.WorkflowType, name, desc, defaults),
},
})
}
}
return results, nil
}
+27
View File
@@ -0,0 +1,27 @@
package prediction
import (
"testing"
"github.com/rose_cat707/luminary/internal/model"
)
func TestSplitOwnerName(t *testing.T) {
owner, name := splitOwnerName("luminary/flux-sdxl", "comfy-local")
if owner != "luminary" || name != "flux-sdxl" {
t.Fatalf("got %q/%q", owner, name)
}
owner, name = splitOwnerName("plain-model", "")
if owner != "luminary" || name != "plain-model" {
t.Fatalf("got %q/%q", owner, name)
}
}
func TestModelDescription(t *testing.T) {
if modelDescription(model.ModelEntry{WorkflowType: model.WorkflowImg2Img}) == "" {
t.Fatal("expected default img2img description")
}
if got := modelDescription(model.ModelEntry{DisplayName: "FLUX"}); got != "FLUX" {
t.Fatalf("got %q", got)
}
}
+54 -20
View File
@@ -51,6 +51,59 @@ func ParamsForWorkflowType(wt model.WorkflowType) []ParamDef {
}
}
func openAPIType(pt ParamType) string {
switch pt {
case ParamInt:
return "integer"
case ParamFloat:
return "number"
default:
return "string"
}
}
// OpenAPISchemaForWorkflow builds a Replicate-compatible schema with components.schemas.Input.
func OpenAPISchemaForWorkflow(wt model.WorkflowType, title, description string, overrides map[string]any) map[string]any {
defs := ParamsForWorkflowType(wt)
properties := make(map[string]any, len(defs))
required := make([]string, 0, len(defs))
for i, d := range defs {
prop := map[string]any{
"type": openAPIType(d.Type),
"title": d.Label,
"description": d.Label,
"x-order": i,
}
if def, ok := effectiveDefault(d, overrides); ok {
prop["default"] = def
}
properties[d.Name] = prop
if d.Required {
required = append(required, d.Name)
}
}
inputSchema := map[string]any{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
inputSchema["required"] = required
}
return map[string]any{
"openapi": "3.0.0",
"info": map[string]any{
"title": title,
"version": "1.0.0",
"description": description,
},
"components": map[string]any{
"schemas": map[string]any{
"Input": inputSchema,
},
},
}
}
func RequiredBindings(wt model.WorkflowType) []string {
switch wt {
case model.WorkflowImg2Img:
@@ -61,26 +114,7 @@ func RequiredBindings(wt model.WorkflowType) []string {
}
func ValidateInput(wt model.WorkflowType, input map[string]any) (map[string]any, error) {
defs := ParamsForWorkflowType(wt)
out := make(map[string]any, len(defs))
for _, d := range defs {
v, ok := input[d.Name]
if !ok || v == nil {
if d.Required {
return nil, fmt.Errorf("missing required field: %s", d.Name)
}
if d.Default != nil {
out[d.Name] = d.Default
}
continue
}
coerced, err := coerceValue(d, v)
if err != nil {
return nil, fmt.Errorf("%s: %w", d.Name, err)
}
out[d.Name] = coerced
}
return out, nil
return ValidateInputWithDefaults(wt, input, nil)
}
func coerceValue(d ParamDef, v any) (any, error) {
+52
View File
@@ -0,0 +1,52 @@
package prediction
import (
"testing"
"github.com/rose_cat707/luminary/internal/model"
)
func TestOpenAPISchemaForWorkflowTxt2Img(t *testing.T) {
schema := OpenAPISchemaForWorkflow(model.WorkflowTxt2Img, "flux", "FLUX txt2img", nil)
components, ok := schema["components"].(map[string]any)
if !ok {
t.Fatal("missing components")
}
schemas, ok := components["schemas"].(map[string]any)
if !ok {
t.Fatal("missing schemas")
}
input, ok := schemas["Input"].(map[string]any)
if !ok {
t.Fatal("missing Input schema")
}
props, ok := input["properties"].(map[string]any)
if !ok {
t.Fatal("missing properties")
}
if _, ok := props["prompt"]; !ok {
t.Fatal("missing prompt property")
}
if _, ok := props["image"]; ok {
t.Fatal("txt2img should not expose image input")
}
required, ok := input["required"].([]string)
if !ok || len(required) == 0 || required[0] != "prompt" {
t.Fatalf("unexpected required: %#v", input["required"])
}
}
func TestOpenAPISchemaForWorkflowImg2Img(t *testing.T) {
schema := OpenAPISchemaForWorkflow(model.WorkflowImg2Img, "flux-i2i", "FLUX img2img", nil)
components := schema["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
input := schemas["Input"].(map[string]any)
props := input["properties"].(map[string]any)
if _, ok := props["image"]; !ok {
t.Fatal("missing image property")
}
required := input["required"].([]string)
if len(required) != 2 {
t.Fatalf("expected prompt and image required, got %#v", required)
}
}
+41 -11
View File
@@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"path"
"strings"
"sync"
"time"
@@ -67,7 +66,7 @@ func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req Cre
if !provider.Enabled {
return nil, fmt.Errorf("provider disabled")
}
input, err := ValidateInput(entry.WorkflowType, req.Input)
input, err := ValidateInputWithDefaults(entry.WorkflowType, req.Input, inputDefaultsForEntry(entry))
if err != nil {
return nil, err
}
@@ -85,14 +84,15 @@ func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req Cre
if err != nil {
return nil, err
}
ingressModelID := entry.IngressID()
inputJSON, _ := json.Marshal(input)
p := model.Prediction{
ID: id,
IngressKeyID: ingress.ID,
ProviderID: provider.ID,
ModelEntryID: entry.ID,
Version: req.Version,
Model: entry.ModelID,
Version: ingressModelID,
Model: ingressModelID,
InputJSON: string(inputJSON),
Status: model.PredictionStarting,
ClientID: "luminary-" + id,
@@ -129,6 +129,14 @@ func (s *Service) Create(ctx context.Context, ingress *model.IngressKey, req Cre
return s.toAPI(&p), nil
}
func inputDefaultsForEntry(entry model.ModelEntry) map[string]any {
defs, err := ParseInputDefaults(entry.InputDefaultsJSON)
if err != nil {
return map[string]any{}
}
return defs
}
func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model.ModelEntry, model.Provider, error) {
allowedProviders := cache.ParseJSONList(ingress.AllowedProvidersJSON)
allowedModels := cache.ParseJSONList(ingress.AllowedModelsJSON)
@@ -145,7 +153,7 @@ func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model
continue
}
ingressID := m.IngressID()
if !m.MatchesIngressName(version) {
if !m.MatchesIngressRef(version, p.Name) {
continue
}
if !cache.MatchesList(allowedModels, ingressID) && !cache.MatchesList(allowedModels, m.ModelID) && !cache.MatchesList(allowedModels, "*") {
@@ -157,6 +165,12 @@ func (s *Service) resolveModel(version string, ingress *model.IngressKey) (model
if len(candidates) == 0 {
return model.ModelEntry{}, model.Provider{}, fmt.Errorf("no model found for version %s", version)
}
if len(candidates) > 1 && !strings.Contains(version, "/") {
return model.ModelEntry{}, model.Provider{}, fmt.Errorf("ambiguous model name %q; use owner/name", version)
}
if len(candidates) > 1 {
return model.ModelEntry{}, model.Provider{}, fmt.Errorf("ambiguous model %q", version)
}
m := candidates[0]
p, ok := s.cache.GetProvider(m.ProviderID)
if !ok {
@@ -180,15 +194,11 @@ func (s *Service) execute(ctx context.Context, p *model.Prediction, entry model.
runInput := cloneMap(input)
if entry.WorkflowType == model.WorkflowImg2Img {
imageURL, _ := runInput["image"].(string)
data, err := downloadURL(ctx, imageURL)
data, name, err := s.loadInputImage(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))
@@ -409,6 +419,26 @@ func formatTime(t *time.Time) any {
return t.UTC().Format(time.RFC3339Nano)
}
func (s *Service) loadInputImage(ctx context.Context, imageURL string) ([]byte, string, error) {
if id, ok := parseGatewayFileID(imageURL); ok && s.images != nil {
data, img, err := s.images.ReadContent(id)
if err == nil {
if err := validateImageBytes(data); err != nil {
return nil, "", err
}
return data, imageFilenameFromStored(img.Filename, img.Mime, data), nil
}
}
data, err := downloadURL(ctx, imageURL)
if err != nil {
return nil, "", err
}
if err := validateImageBytes(data); err != nil {
return nil, "", err
}
return data, imageFilenameFromURL(imageURL, data), nil
}
func downloadURL(ctx context.Context, raw string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
@@ -455,7 +485,7 @@ func (s *Service) recordUsage(ingress *model.IngressKey, provider *model.Provide
ProviderID: providerID,
ClientIP: clientIP,
Endpoint: model.EndpointPrediction,
Model: p.ID,
Model: p.Model,
LatencyMs: time.Since(start).Milliseconds(),
Status: status,
ErrorMsg: errMsg,
+11 -2
View File
@@ -146,8 +146,16 @@ func (c *Client) DownloadView(ctx context.Context, filename, subfolder, imageTyp
}
func (c *Client) UploadImage(ctx context.Context, data []byte, filename string) (string, error) {
if len(data) == 0 {
return "", fmt.Errorf("empty image payload")
}
if strings.TrimSpace(filename) == "" {
filename = "input.png"
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
_ = w.WriteField("type", "input")
_ = w.WriteField("overwrite", "true")
part, err := w.CreateFormFile("image", filename)
if err != nil {
return "", err
@@ -155,8 +163,9 @@ func (c *Client) UploadImage(ctx context.Context, data []byte, filename string)
if _, err := part.Write(data); err != nil {
return "", err
}
_ = w.WriteField("overwrite", "true")
_ = w.Close()
if err := w.Close(); err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/upload/image", &buf)
if err != nil {
return "", err
+12
View File
@@ -74,6 +74,18 @@ func (s *Store) Get(id string) (*model.StoredImage, error) {
return &img, nil
}
func (s *Store) ReadContent(id string) ([]byte, *model.StoredImage, error) {
img, err := s.Get(id)
if err != nil {
return nil, nil, err
}
data, err := os.ReadFile(img.LocalPath)
if err != nil {
return nil, nil, err
}
return data, img, nil
}
func (s *Store) ListByPrediction(predictionID string) ([]model.StoredImage, error) {
var imgs []model.StoredImage
err := s.db.Where("prediction_id = ?", predictionID).Order("created_at asc").Find(&imgs).Error
+10
View File
@@ -49,9 +49,19 @@ func New(path string) (*Store, error) {
); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
if err := ensureIngressKeyProtocol(db); err != nil {
return nil, fmt.Errorf("migrate ingress protocol: %w", err)
}
return &Store{db: db}, nil
}
func ensureIngressKeyProtocol(db *gorm.DB) error {
if !tableExists(db, "ingress_keys") {
return nil
}
return db.Exec(`UPDATE ingress_keys SET protocol = ? WHERE protocol IS NULL OR protocol = ''`, model.IngressProtocolText).Error
}
// ensureIPRulesSchema manages ip_rules without GORM AutoMigrate (avoids bad table rebuilds).
func ensureIPRulesSchema(db *gorm.DB) error {
if !tableExists(db, "ip_rules") {