feat: publish sanitized custom.97 source

This commit is contained in:
cneicy committed 2026-10-27 20:43:39 +08:00
1 parent 8e6ed0317b
commit 86c0276156
1022 files changed
+69903 -19207

No files matched your search

+22 -1
View File
@@ -78,6 +78,8 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
return a.claudeAdaptor.ConvertClaudeRequest(c, info, request)
case relayconvert.ConverterClaudeMessagesToOpenAIChat:
return a.convertCrossProtocolChatRequest(c, info, converter, request)
case relayconvert.ConverterClaudeMessagesToOpenAIResponses:
return convertCrossProtocolResponsesRequest(c, info, converter, request)
default:
return nil, fmt.Errorf("converter %q does not support Anthropic Messages requests", converter)
}
@@ -94,6 +96,8 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
return a.geminiAdaptor.ConvertGeminiRequest(c, info, request)
case relayconvert.ConverterGeminiContentToOpenAIChat:
return a.convertCrossProtocolChatRequest(c, info, converter, request)
case relayconvert.ConverterGeminiContentToOpenAIResponses:
return convertCrossProtocolResponsesRequest(c, info, converter, request)
default:
return nil, fmt.Errorf("converter %q does not support Gemini generateContent requests", converter)
}
@@ -296,7 +300,10 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
return a.geminiAdaptor.DoResponse(c, resp, info)
case relayconvert.ConverterOpenAIResponsesToGemini:
return a.geminiAdaptor.DoResponse(c, resp, info)
case relayconvert.ConverterOpenAIChatToOpenAIResponses:
case relayconvert.ConverterOpenAIChatToOpenAIResponses,
relayconvert.ConverterClaudeMessagesToOpenAIResponses,
relayconvert.ConverterGeminiContentToOpenAIResponses:
// These handlers convert the Responses upstream into info.RelayFormat.
if info.IsStream {
return openai.OaiResponsesToChatStreamHandler(c, info, resp)
}
@@ -531,6 +538,20 @@ func (a *Adaptor) convertCrossProtocolChatRequest(c *gin.Context, info *relaycom
return a.convertOpenAICompatibleRequest(c, info, chatRequest)
}
// convertCrossProtocolResponsesRequest converts a Claude or Gemini client
// request for an OpenAI Responses upstream.
func convertCrossProtocolResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, converter string, request any) (any, error) {
result, err := service.ConvertRequestByID(c, info, converter, request)
if err != nil {
return nil, err
}
responsesRequest, ok := result.Value.(*dto.OpenAIResponsesRequest)
if !ok {
return nil, fmt.Errorf("expected OpenAI Responses request, got %T", result.Value)
}
return responsesRequest, nil
}
func (a *Adaptor) convertOpenAICompatibleRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) {
old := info.ChannelType
info.ChannelType = constant.ChannelTypeOpenAI
@@ -2,10 +2,12 @@ package advancedcustom
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
@@ -19,6 +21,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAdaptorUsesExactRouteAndQueryAuth(t *testing.T) {
@@ -51,6 +54,47 @@ func TestAdaptorUsesExactRouteAndQueryAuth(t *testing.T) {
assert.Equal(t, "sk-test", parsedURL.Query().Get("api_key"))
}
// An absolute route sends the request to a host other than the channel base
// URL, and a channel proxy is dialed before the upstream; a failed handshake
// must hide both.
func TestAdaptorMasksDialedHostsInDialFailure(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer upstream.Close()
upstreamURL, err := url.Parse(upstream.URL)
require.NoError(t, err)
for _, tc := range []struct {
name string
proxy string
hidden string
}{
{name: "absolute route host", hidden: "localhost"},
{name: "channel proxy host", proxy: "http://egress.private.invalid:8080", hidden: "egress.private.invalid"},
} {
t.Run(tc.name, func(t *testing.T) {
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{{IncomingPath: "/v1/realtime", UpstreamPath: "http://localhost:" + upstreamURL.Port() + "/v1/realtime", Converter: relayconvert.ConverterNone}},
})
info.RelayMode = relayconstant.RelayModeRealtime
info.RequestURLPath = "/v1/realtime"
info.ChannelSetting.Proxy = tc.proxy
c := advancedCustomGinContext("/v1/realtime")
common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, info.ChannelBaseUrl)
common.SetContextKey(c, constant.ContextKeyChannelSetting, info.ChannelSetting)
_, err := (&Adaptor{}).DoRequest(c, info, nil)
var apiErr *types.NewAPIError
require.ErrorAs(t, err, &apiErr)
require.Contains(t, apiErr.Error(), tc.hidden)
message := apiErr.ToOpenAIError(common.UpstreamAddresses(c)...).Message
assert.Contains(t, message, "dial failed to ws://***:"+upstreamURL.Port()+"/***/***")
assert.NotContains(t, message, tc.hidden)
})
}
}
func TestAdaptorJoinsUpstreamPathWithChannelBaseURL(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
@@ -966,6 +1010,98 @@ func TestAdaptorCrossProtocolChatUpstreamRequestsStreamUsage(t *testing.T) {
}
}
// Claude Messages and Gemini Generate Content clients on an OpenAI Responses
// upstream: the request carries the hosted web search as a Responses tool and
// the Responses answer, JSON or stream, is converted back to the client format.
func TestAdaptorConvertsClaudeAndGeminiRequestsToResponsesUpstream(t *testing.T) {
oldTimeout := constant.StreamingTimeout
constant.StreamingTimeout = 30
t.Cleanup(func() {
constant.StreamingTimeout = oldTimeout
})
const responsesJSON = `{"id":"resp_1","object":"response","model":"gpt-test","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Go 1.25","annotations":[]}]}],"usage":{"input_tokens":12,"output_tokens":4,"total_tokens":16}}`
responsesStream := "data: " + `{"type":"response.created","sequence_number":0,"response":{"id":"resp_1","object":"response","model":"gpt-test","status":"in_progress","output":[]}}` + "\n\n" +
"data: " + `{"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"type":"message","id":"msg_1","role":"assistant","status":"in_progress","content":[]}}` + "\n\n" +
"data: " + `{"type":"response.output_text.delta","sequence_number":2,"output_index":0,"item_id":"msg_1","content_index":0,"delta":"Go 1.25"}` + "\n\n" +
"data: " + `{"type":"response.completed","sequence_number":3,"response":` + responsesJSON + `}` + "\n\n"
tests := []struct {
name string
route dto.AdvancedCustomRoute
relayFormat types.RelayFormat
requestPath string
convert func(*Adaptor, *gin.Context, *relaycommon.RelayInfo) (any, error)
wantResponse map[bool]string // stream -> marker in the client body
}{
{
name: "claude",
route: dto.AdvancedCustomRoute{IncomingPath: "/v1/messages", UpstreamPath: "/v1/responses", Converter: relayconvert.ConverterClaudeMessagesToOpenAIResponses},
relayFormat: types.RelayFormatClaude,
requestPath: "/v1/messages",
convert: func(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (any, error) {
return a.ConvertClaudeRequest(c, info, &dto.ClaudeRequest{
Model: "gpt-test",
Messages: []dto.ClaudeMessage{{Role: "user", Content: "Latest Go?"}},
Tools: []any{map[string]any{"type": "web_search_20250305", "name": "web_search"}},
})
},
wantResponse: map[bool]string{false: `"type":"message"`, true: "event: message_stop"},
},
{
name: "gemini",
route: dto.AdvancedCustomRoute{IncomingPath: "/v1beta/models/{model}:generateContent", UpstreamPath: "/v1/responses", Converter: relayconvert.ConverterGeminiContentToOpenAIResponses},
relayFormat: types.RelayFormatGemini,
requestPath: "/v1beta/models/gpt-test:generateContent",
convert: func(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (any, error) {
return a.ConvertGeminiRequest(c, info, &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{{Role: "user", Parts: []dto.GeminiPart{{Text: "Latest Go?"}}}},
Tools: mustAdvancedCustomRawMessage(t, []map[string]any{{"googleSearch": map[string]any{}}}),
})
},
wantResponse: map[bool]string{false: `"candidates"`, true: `"candidates"`},
},
}
for _, tt := range tests {
for _, stream := range []bool{false, true} {
t.Run(fmt.Sprintf("%s stream=%v", tt.name, stream), func(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{Routes: []dto.AdvancedCustomRoute{tt.route}})
info.RelayFormat = tt.relayFormat
info.RequestURLPath = tt.requestPath
info.IsStream = stream
info.DisablePing = true
info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{LastMessagesType: relaycommon.LastMessageTypeNone}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, tt.requestPath, nil)
converted, err := tt.convert(adaptor, c, info)
require.NoError(t, err)
wire, err := common.Marshal(converted)
require.NoError(t, err)
assert.JSONEq(t, `[{"type":"web_search"}]`, gjson.GetBytes(wire, "tools").Raw)
url, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://fallback.example/v1/responses", url)
upstream, contentType := responsesJSON, "application/json"
if stream {
upstream, contentType = responsesStream, "text/event-stream"
}
usage, apiErr := adaptor.DoResponse(c, &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(upstream)),
}, info)
require.Nil(t, apiErr)
assert.Equal(t, 12, usage.(*dto.Usage).PromptTokens)
assert.Contains(t, recorder.Body.String(), "Go 1.25")
assert.Contains(t, recorder.Body.String(), tt.wantResponse[stream])
})
}
}
}
func advancedCustomRelayInfo(config *dto.AdvancedCustomConfig) *relaycommon.RelayInfo {
return &relaycommon.RelayInfo{
RelayFormat: types.RelayFormatOpenAI,