Split ingress keys into text/image protocols with Replicate model listing.
CI / docker (push) Successful in 3m38s
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user