From 6915d616721d4ed033e17ec2bc9cf4f2943f224e Mon Sep 17 00:00:00 2001 From: Robinnnnn <12162433+Robinnnnn@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:30:42 -0700 Subject: [PATCH 1/4] feat: support OpenRouter image generation endpoint OpenRouter's image generation API lives at POST {base}/v1/images (https://openrouter.ai/docs/features/multimodal/image-generation-api), not the OpenAI-style /v1/images/generations. Route image generation requests on OpenRouter channels to that path so downstream /v1/images/generations requests relay correctly. The existing OpenAI image handler already parses OpenRouter's response shape (created/data[].b64_json/usage). --- relay/channel/openai/adaptor.go | 6 ++ relay/channel/openai/openrouter_image_test.go | 62 +++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 relay/channel/openai/openrouter_image_test.go diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 2c230107de37..f33a1d4e7410 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -107,6 +107,12 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { info.ChannelBaseUrl = baseUrl } } + // 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.ChannelType == constant.ChannelTypeOpenRouter && info.RelayMode == relayconstant.RelayModeImagesGenerations { + return fmt.Sprintf("%s/v1/images", info.ChannelBaseUrl), nil + } switch info.ChannelType { case constant.ChannelTypeAzure: apiVersion := info.ApiVersion diff --git a/relay/channel/openai/openrouter_image_test.go b/relay/channel/openai/openrouter_image_test.go new file mode 100644 index 000000000000..3e33f300eb99 --- /dev/null +++ b/relay/channel/openai/openrouter_image_test.go @@ -0,0 +1,62 @@ +package openai + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + relaycommon "github.com/QuantumNous/new-api/relay/common" + relayconstant "github.com/QuantumNous/new-api/relay/constant" +) + +// 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) + if err != nil { + t.Fatalf("GetRequestURL returned error: %v", err) + } + + want := "https://openrouter.ai/api/v1/images" + if got != want { + t.Fatalf("GetRequestURL() = %q, want %q", got, want) + } +} + +// 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) + if err != nil { + t.Fatalf("GetRequestURL returned error: %v", err) + } + + want := "https://openrouter.ai/api/v1/chat/completions" + if got != want { + t.Fatalf("GetRequestURL() = %q, want %q", got, want) + } +} From db1fa91979318285f774f9b4d3a2c745d89f9307 Mon Sep 17 00:00:00 2001 From: Robinnnnn <12162433+Robinnnnn@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:37:51 -0700 Subject: [PATCH 2/4] test: use testify require/assert in OpenRouter image URL tests --- relay/channel/openai/openrouter_image_test.go | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/relay/channel/openai/openrouter_image_test.go b/relay/channel/openai/openrouter_image_test.go index 3e33f300eb99..c6dc11f2dac5 100644 --- a/relay/channel/openai/openrouter_image_test.go +++ b/relay/channel/openai/openrouter_image_test.go @@ -6,6 +6,8 @@ import ( "github.com/QuantumNous/new-api/constant" 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 @@ -25,14 +27,8 @@ func TestGetRequestURLOpenRouterImageGeneration(t *testing.T) { } got, err := adaptor.GetRequestURL(info) - if err != nil { - t.Fatalf("GetRequestURL returned error: %v", err) - } - - want := "https://openrouter.ai/api/v1/images" - if got != want { - t.Fatalf("GetRequestURL() = %q, want %q", got, want) - } + require.NoError(t, err) + assert.Equal(t, "https://openrouter.ai/api/v1/images", got) } // TestGetRequestURLOpenRouterChatUnchanged guards against the image special @@ -51,12 +47,6 @@ func TestGetRequestURLOpenRouterChatUnchanged(t *testing.T) { } got, err := adaptor.GetRequestURL(info) - if err != nil { - t.Fatalf("GetRequestURL returned error: %v", err) - } - - want := "https://openrouter.ai/api/v1/chat/completions" - if got != want { - t.Fatalf("GetRequestURL() = %q, want %q", got, want) - } + require.NoError(t, err) + assert.Equal(t, "https://openrouter.ai/api/v1/chat/completions", got) } From 017cf4591447c63da6ca56bb2fb8a3f5a123c5d6 Mon Sep 17 00:00:00 2001 From: Robinnnnn <12162433+Robinnnnn@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:06:04 -0700 Subject: [PATCH 3/4] refactor: fold OpenRouter image URL special case into channel switch --- relay/channel/openai/adaptor.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index f33a1d4e7410..5e15ee46493a 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -107,12 +107,6 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { info.ChannelBaseUrl = baseUrl } } - // 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.ChannelType == constant.ChannelTypeOpenRouter && info.RelayMode == relayconstant.RelayModeImagesGenerations { - return fmt.Sprintf("%s/v1/images", info.ChannelBaseUrl), nil - } switch info.ChannelType { case constant.ChannelTypeAzure: apiVersion := info.ApiVersion @@ -169,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 && From 7684bf2d40317ec9efde5b8185eedd491e34e4ef Mon Sep 17 00:00:00 2001 From: Robinnnnn <12162433+Robinnnnn@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:53:33 -0700 Subject: [PATCH 4/4] feat: forward OpenRouter-specific image params captured in Extra The generic dto.ImageRequest serialization drops unknown fields (aspect_ratio, resolution, seed, input_references, provider) that OpenRouter's /v1/images endpoint accepts. Merge Extra back into the outbound body for OpenRouter image generation requests only, leaving all other channels' serialization unchanged. --- relay/channel/openai/adaptor.go | 6 ++ relay/channel/openai/openrouter_image.go | 31 +++++++ relay/channel/openai/openrouter_image_test.go | 88 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 relay/channel/openai/openrouter_image.go diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go index 5e15ee46493a..80a4258b8fcc 100644 --- a/relay/channel/openai/adaptor.go +++ b/relay/channel/openai/adaptor.go @@ -564,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 } } diff --git a/relay/channel/openai/openrouter_image.go b/relay/channel/openai/openrouter_image.go new file mode 100644 index 000000000000..4acc9354c810 --- /dev/null +++ b/relay/channel/openai/openrouter_image.go @@ -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 +} diff --git a/relay/channel/openai/openrouter_image_test.go b/relay/channel/openai/openrouter_image_test.go index c6dc11f2dac5..b5bc8dcd3ebd 100644 --- a/relay/channel/openai/openrouter_image_test.go +++ b/relay/channel/openai/openrouter_image_test.go @@ -1,9 +1,12 @@ 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" @@ -50,3 +53,88 @@ func TestGetRequestURLOpenRouterChatUnchanged(t *testing.T) { 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"])) +}