Skip to content
Open
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
14 changes: 14 additions & 0 deletions relay/channel/openai/adaptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,14 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
url := info.ChannelBaseUrl
url = strings.Replace(url, "{model}", info.UpstreamModelName, -1)
return url, nil
case constant.ChannelTypeOpenRouter:
// OpenRouter's image generation endpoint is POST {base}/v1/images
// (https://openrouter.ai/docs/features/multimodal/image-generation-api),
// not the OpenAI-style /v1/images/generations.
if info.RelayMode == relayconstant.RelayModeImagesGenerations {
return fmt.Sprintf("%s/v1/images", info.ChannelBaseUrl), nil
}
fallthrough
default:
if (info.RelayFormat == types.RelayFormatClaude || info.RelayFormat == types.RelayFormatGemini) &&
info.RelayMode != relayconstant.RelayModeResponses &&
Expand Down Expand Up @@ -556,6 +564,12 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
return &requestBody, nil

default:
// OpenRouter's /v1/images endpoint accepts params outside the OpenAI
// image schema, so merge the unknown fields captured in Extra back
// into the outbound body for this channel only.
if info.ChannelType == constant.ChannelTypeOpenRouter && info.RelayMode == relayconstant.RelayModeImagesGenerations {
return mergeImageRequestExtra(request)
}
return request, nil
}
}
Expand Down
31 changes: 31 additions & 0 deletions relay/channel/openai/openrouter_image.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package openai

import (
"encoding/json"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
)

// mergeImageRequestExtra flattens the unknown fields captured in
// ImageRequest.Extra back into the outbound JSON body. OpenRouter's
// /v1/images endpoint accepts params outside the OpenAI image schema
// (aspect_ratio, resolution, seed, input_references, provider), which
// the generic ImageRequest serialization drops. Known fields always
// win over Extra entries with the same key.
func mergeImageRequestExtra(request dto.ImageRequest) (map[string]json.RawMessage, error) {
base, err := common.Marshal(request)
if err != nil {
return nil, err
}
var bodyMap map[string]json.RawMessage
if err := common.Unmarshal(base, &bodyMap); err != nil {
return nil, err
}
for k, v := range request.Extra {
if _, exists := bodyMap[k]; !exists {
bodyMap[k] = v
}
}
return bodyMap, nil
}
140 changes: 140 additions & 0 deletions relay/channel/openai/openrouter_image_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package openai

import (
"encoding/json"
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestGetRequestURLOpenRouterImageGeneration verifies that image generation
// requests to an OpenRouter channel are sent to OpenRouter's flat
// {base}/v1/images endpoint instead of the OpenAI-style /v1/images/generations.
func TestGetRequestURLOpenRouterImageGeneration(t *testing.T) {
t.Parallel()

adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
RequestURLPath: "/v1/images/generations",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenRouter,
ChannelBaseUrl: "https://openrouter.ai/api",
},
}

got, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://openrouter.ai/api/v1/images", got)
}

// TestGetRequestURLOpenRouterChatUnchanged guards against the image special
// case leaking into the chat completions path for OpenRouter channels.
func TestGetRequestURLOpenRouterChatUnchanged(t *testing.T) {
t.Parallel()

adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeChatCompletions,
RequestURLPath: "/v1/chat/completions",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenRouter,
ChannelBaseUrl: "https://openrouter.ai/api",
},
}

got, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://openrouter.ai/api/v1/chat/completions", got)
}

// TestConvertImageRequestOpenRouterMergesExtra verifies that OpenRouter-specific
// image generation params captured in ImageRequest.Extra (aspect_ratio, seed,
// provider, ...) are merged back into the outbound body for OpenRouter channels,
// alongside the known OpenAI fields.
func TestConvertImageRequestOpenRouterMergesExtra(t *testing.T) {
t.Parallel()

body := `{
"model": "google/gemini-2.5-flash-image",
"prompt": "a cat wearing a hat",
"aspect_ratio": "16:9",
"seed": 42,
"provider": {"options": {"only": ["google-vertex"]}}
}`
var request dto.ImageRequest
require.NoError(t, common.Unmarshal([]byte(body), &request))
require.Contains(t, request.Extra, "aspect_ratio")

adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
RequestURLPath: "/v1/images/generations",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenRouter,
ChannelBaseUrl: "https://openrouter.ai/api",
},
}

converted, err := adaptor.ConvertImageRequest(nil, info, request)
require.NoError(t, err)

serialized, err := common.Marshal(converted)
require.NoError(t, err)

var got map[string]json.RawMessage
require.NoError(t, common.Unmarshal(serialized, &got))

assert.JSONEq(t, `"google/gemini-2.5-flash-image"`, string(got["model"]))
assert.JSONEq(t, `"a cat wearing a hat"`, string(got["prompt"]))
assert.JSONEq(t, `"16:9"`, string(got["aspect_ratio"]))
assert.JSONEq(t, `42`, string(got["seed"]))
assert.JSONEq(t, `{"options": {"only": ["google-vertex"]}}`, string(got["provider"]))
}

// TestConvertImageRequestNonOpenRouterDropsExtra guards the owner's constraint
// that Extra must NOT be merged globally: for non-OpenRouter channels the
// serialized body keeps dropping unknown fields.
func TestConvertImageRequestNonOpenRouterDropsExtra(t *testing.T) {
t.Parallel()

body := `{
"model": "gpt-image-1",
"prompt": "a cat wearing a hat",
"aspect_ratio": "16:9",
"seed": 42
}`
var request dto.ImageRequest
require.NoError(t, common.Unmarshal([]byte(body), &request))
require.Contains(t, request.Extra, "aspect_ratio")

adaptor := &Adaptor{}
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeImagesGenerations,
RequestURLPath: "/v1/images/generations",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenAI,
ChannelBaseUrl: "https://api.openai.com",
},
}

converted, err := adaptor.ConvertImageRequest(nil, info, request)
require.NoError(t, err)

serialized, err := common.Marshal(converted)
require.NoError(t, err)

var got map[string]json.RawMessage
require.NoError(t, common.Unmarshal(serialized, &got))

assert.NotContains(t, got, "aspect_ratio")
assert.NotContains(t, got, "seed")
assert.JSONEq(t, `"gpt-image-1"`, string(got["model"]))
assert.JSONEq(t, `"a cat wearing a hat"`, string(got["prompt"]))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.