Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 35 additions & 7 deletions core/providers/runware/images.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package runware

import (
"fmt"
"net/url"
"strings"

"github.com/google/uuid"
Expand Down Expand Up @@ -68,13 +69,17 @@ func ToRunwareImageGenerationRequest(bifrostReq *schemas.BifrostImageGenerationR
// input_images drives image-to-image on the generation path. A seedImage supplied through
// extra params is the provider-native form of the same thing and wins outright — sending
// both would put two input keys on a request that accepts one.
// Empty entries are dropped: Runware rejects a blank input key, and the edit path already
// filters the same way through runwareImageInput.
// Each entry is normalized the way the other image providers normalize theirs; empty ones are
// dropped, since Runware rejects a blank input key.
if request.SeedImage == nil {
inputImages := make([]string, 0, len(params.InputImages))
for _, img := range params.InputImages {
if trimmed := strings.TrimSpace(img); trimmed != "" {
inputImages = append(inputImages, trimmed)
reference, err := runwareImageReference(img)
if err != nil {
return nil, fmt.Errorf("invalid input image: %w", err)
}
if reference != "" {
inputImages = append(inputImages, reference)
}
}
if len(inputImages) > 0 {
Expand Down Expand Up @@ -177,18 +182,41 @@ func ToRunwareImageEditRequest(bifrostReq *schemas.BifrostImageEditRequest) (*Ru
}

// runwareImageInput resolves an input image to the reference Runware expects. A caller-supplied
// URL passes through untouched — Runware accepts UUIDs and URLs natively, so forwarding it avoids
// round-tripping the asset through the gateway as base64 — while raw bytes become a data URI.
// URL is normalized rather than round-tripped through the gateway as base64; raw bytes become a
// data URI. An unusable reference yields "", which callers treat as absent.
func runwareImageInput(img schemas.ImageInput) string {
if img.URL != "" {
return img.URL
reference, err := runwareImageReference(img.URL)
if err != nil {
return ""
}
return reference
Comment thread
TejasGhatte marked this conversation as resolved.
}
if len(img.Image) == 0 {
return ""
}
return providerUtils.FileBytesToBase64DataURL(img.Image)
}

// runwareImageReference normalizes a caller-supplied image reference. URLs and base64 payloads go
// through the same sanitizer the other image providers use, which validates data URLs and wraps
// bare base64 into one. A value carrying no URL scheme is left alone: Runware accepts its own asset
// UUIDs as inputs — the ids it returns on data[].id — and those would otherwise be rejected as
// schemeless URLs. Returns "" for an empty reference so callers can skip it.
func runwareImageReference(image string) (string, error) {
trimmed := strings.TrimSpace(image)
if trimmed == "" {
return "", nil
}
if sanitized, err := schemas.SanitizeImageURL(trimmed); err == nil {
return sanitized, nil
} else if parsed, parseErr := url.Parse(trimmed); parseErr != nil || parsed.Scheme != "" {
// A scheme means it was meant to be a URL, so a sanitizer failure is a real error.
return "", err
}
return trimmed, nil
}

// runwareImageEditTaskType maps the neutral edit type onto a Runware tool task type. An empty
// result means the edit runs as a regular imageInference task (image-to-image, inpainting,
// outpainting).
Expand Down
48 changes: 48 additions & 0 deletions core/providers/runware/images_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -755,3 +755,51 @@ func TestToRunwareImageGenerationRequest_InputImagesSkipsEmpty(t *testing.T) {
t.Fatalf("all-empty input_images must leave no input key, got seedImage=%v inputs=%+v", blank.SeedImage, blank.Inputs)
}
}

// Input images are normalized the way the other image providers normalize theirs: bare base64 is
// wrapped into a data URI, malformed URLs are rejected, and Runware's own asset UUIDs survive —
// those carry no scheme, so a generic URL sanitizer would otherwise reject them.
func TestRunwareImageReference(t *testing.T) {
const bareBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
for _, tc := range []struct {
name, in, want string
wantErr bool
}{
{"asset uuid preserved", "2f670f32-dece-4c44-aef0-e62b52ca7d55", "2f670f32-dece-4c44-aef0-e62b52ca7d55", false},
{"https untouched", "https://example.com/a.jpg", "https://example.com/a.jpg", false},
{"data uri untouched", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo=", false},
{"bare base64 wrapped", bareBase64, "data:image/png;base64," + bareBase64, false},
{"whitespace trimmed", " 2f670f32-dece-4c44-aef0-e62b52ca7d55 ", "2f670f32-dece-4c44-aef0-e62b52ca7d55", false},
{"empty yields empty", " ", "", false},
{"malformed data url errors", "data:garbage", "", true},
{"disallowed scheme errors", "ftp://example.com/a.jpg", "", true},
} {
got, err := runwareImageReference(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("%s: expected an error, got %q", tc.name, got)
}
continue
}
if err != nil {
t.Errorf("%s: unexpected error: %v", tc.name, err)
continue
}
if got != tc.want {
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
}
}
}

// The generation path rejects a malformed reference rather than forwarding it for Runware to
// reject, matching how replicate and runway handle input_images.
func TestToRunwareImageGenerationRequest_InputImagesRejectsMalformed(t *testing.T) {
_, err := ToRunwareImageGenerationRequest(&schemas.BifrostImageGenerationRequest{
Model: "runware:101@1",
Input: &schemas.ImageGenerationInput{Prompt: "a cat"},
Params: &schemas.ImageGenerationParameters{InputImages: []string{"ftp://example.com/a.jpg"}},
})
if err == nil {
t.Fatalf("expected an error for a disallowed scheme")
}
}
125 changes: 124 additions & 1 deletion core/providers/runware/models.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package runware

import schemas "github.com/maximhq/bifrost/core/schemas"
import (
"slices"
"strings"

providerUtils "github.com/maximhq/bifrost/core/providers/utils"
schemas "github.com/maximhq/bifrost/core/schemas"
)

// runware3DImageInputIsArray records, per 3D model, whether the input image goes into
// inputs.images[] (true) or inputs.image (false). The split is per-model and comes from Runware's
Expand Down Expand Up @@ -237,3 +243,120 @@ func runwareVideoInputFormFor(caps schemas.ModelCaps) runwareVideoInputForm {
}
return runwareVideoInputForms[caps.Model()]
}

// runwareModelSearchPageSize is above the documented maximum of 100, which Runware nonetheless
// honours. It is deliberate: a modelSearch page costs ~10-20s regardless of size, so paging the
// curated catalog at 100 would mean five sequential round trips and blow the request deadline,
// while 500 fetches the whole set in one.
const runwareModelSearchPageSize = 500
Comment thread
TejasGhatte marked this conversation as resolved.

// runwareCuratedSource limits a catalog sweep to Runware's first-party models. The unfiltered
// catalog is dominated by community LoRA uploads (~273k of ~320k) that no Bifrost route targets.
const runwareCuratedSource = "curated"

// ToBifrostListModelsResponse converts Runware catalog pages to a Bifrost list response. The AIR is
// the model identifier every inference task keys off, so it becomes the Bifrost model ID.
func ToBifrostListModelsResponse(
models []RunwareModel,
providerKey schemas.ModelProvider,
allowedModels schemas.WhiteList,
blacklistedModels schemas.BlackList,
aliases schemas.KeyAliases,
unfiltered bool,
) *schemas.BifrostListModelsResponse {
bifrostResponse := &schemas.BifrostListModelsResponse{
Data: make([]schemas.Model, 0, len(models)),
}

pipeline := &providerUtils.ListModelsPipeline{
AllowedModels: allowedModels,
BlacklistedModels: blacklistedModels,
Aliases: aliases,
Unfiltered: unfiltered,
ProviderKey: providerKey,
MatchFns: providerUtils.DefaultMatchFns(),
}
if pipeline.ShouldEarlyExit() {
return bifrostResponse
}

for _, model := range models {
if model.AIR == "" {
continue
}
for _, result := range pipeline.FilterModel(model.AIR) {
bifrostModel := schemas.Model{
ID: string(providerKey) + "/" + result.ResolvedID,
}
if model.Name != "" {
bifrostModel.Name = &model.Name
}
if model.Comment != "" {
bifrostModel.Description = &model.Comment
}
if model.Creator != nil {
if owner := model.Creator.Name; owner != "" {
bifrostModel.OwnedBy = &owner
} else if owner := model.Creator.ID; owner != "" {
bifrostModel.OwnedBy = &owner
}
}
if model.AddedUnixTimestamp > 0 {
bifrostModel.Created = &model.AddedUnixTimestamp
}
// Runware describes a model's shape with capability tags rather than modality lists, so
// the input/output modalities are derived from its "io:<from>-to-<to>" entries.
if architecture := runwareModelArchitecture(model); architecture != nil {
bifrostModel.Architecture = architecture
}
if result.AliasValue != "" {
bifrostModel.Alias = &result.AliasValue
}
bifrostResponse.Data = append(bifrostResponse.Data, bifrostModel)
}
}

return bifrostResponse
}

// runwareModelArchitecture derives modality lists from a model's io: capability tags. Returns nil
// when the entry declares none, so the field stays absent rather than empty.
func runwareModelArchitecture(model RunwareModel) *schemas.Architecture {
inputs := map[string]bool{}
outputs := map[string]bool{}
for _, capability := range model.Capabilities {
pair, ok := strings.CutPrefix(capability, "io:")
if !ok {
continue
}
from, to, found := strings.Cut(pair, "-to-")
if !found {
continue
}
inputs[from] = true
outputs[to] = true
}
if len(inputs) == 0 && len(outputs) == 0 && model.Architecture == "" {
return nil
}
architecture := &schemas.Architecture{
InputModalities: sortedKeys(inputs),
OutputModalities: sortedKeys(outputs),
}
if model.Architecture != "" {
architecture.Tokenizer = &model.Architecture
}
return architecture
}

func sortedKeys(set map[string]bool) []string {
if len(set) == 0 {
return nil
}
keys := make([]string, 0, len(set))
for k := range set {
keys = append(keys, k)
}
slices.Sort(keys)
return keys
}
82 changes: 82 additions & 0 deletions core/providers/runware/models_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package runware

import (
"testing"

schemas "github.com/maximhq/bifrost/core/schemas"
)

func catalogModels() []RunwareModel {
return []RunwareModel{
{
AIR: "xai:grok-imagine@image-2.0",
Name: "Grok Imagine Image 2.0",
Category: "checkpoint",
Architecture: "grok_imagine_image_2_0",
Capabilities: []string{"io:text-to-image", "io:image-to-image", "form:checkpoint"},
Comment: "Next-generation Grok image generation and editing",
Creator: &RunwareModelCreator{ID: "xai", Name: "xAI"},
},
{
AIR: "tripo:v3.1@0",
Name: "Tripo 3.1",
Category: "others",
Capabilities: []string{"io:image-to-3d", "io:text-to-3d"},
AddedUnixTimestamp: 1778112000,
},
{AIR: ""}, // entries with no AIR carry no usable identifier and are skipped
}
}

// The AIR is the identifier every inference task keys off, so it becomes the Bifrost model ID,
// provider-prefixed like every other provider's listing.
func TestToBifrostListModelsResponse(t *testing.T) {
out := ToBifrostListModelsResponse(catalogModels(), schemas.Runware, schemas.WhiteList{"*"}, nil, nil, false)

if len(out.Data) != 2 {
t.Fatalf("expected 2 models (the AIR-less entry skipped), got %d", len(out.Data))
}

grok := out.Data[0]
if grok.ID != "runware/xai:grok-imagine@image-2.0" {
t.Fatalf("id = %q, want the provider-prefixed AIR", grok.ID)
}
if grok.Name == nil || *grok.Name != "Grok Imagine Image 2.0" {
t.Fatalf("name not carried: %v", grok.Name)
}
if grok.OwnedBy == nil || *grok.OwnedBy != "xAI" {
t.Fatalf("owned_by should come from the creator name, got %v", grok.OwnedBy)
}
if grok.Description == nil || *grok.Description == "" {
t.Fatalf("description not carried from comment")
}
// io: tags are the only modality signal Runware gives, so they drive the architecture block.
if grok.Architecture == nil {
t.Fatalf("expected architecture derived from io: capabilities")
}
if got := grok.Architecture.InputModalities; len(got) != 2 || got[0] != "image" || got[1] != "text" {
t.Fatalf("input modalities = %v, want [image text]", got)
}
if got := grok.Architecture.OutputModalities; len(got) != 1 || got[0] != "image" {
t.Fatalf("output modalities = %v, want [image]", got)
}
if grok.Architecture.Tokenizer == nil || *grok.Architecture.Tokenizer != "grok_imagine_image_2_0" {
t.Fatalf("architecture name not carried: %v", grok.Architecture.Tokenizer)
}

tripo := out.Data[1]
if tripo.Created == nil || *tripo.Created != 1778112000 {
t.Fatalf("created not carried: %v", tripo.Created)
}
if got := tripo.Architecture.OutputModalities; len(got) != 1 || got[0] != "3d" {
t.Fatalf("3d output modality = %v", got)
}
}

// A key's allowlist scopes the listing the same way it does for every other provider.
func TestToBifrostListModelsResponse_RespectsKeyAllowlist(t *testing.T) {
out := ToBifrostListModelsResponse(catalogModels(), schemas.Runware, schemas.WhiteList{"tripo:v3.1@0"}, nil, nil, false)
if len(out.Data) != 1 || out.Data[0].ID != "runware/tripo:v3.1@0" {
t.Fatalf("allowlist not applied, got %+v", out.Data)
}
}
Loading
Loading