feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user