diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index 64f622f4389e..eba9de26f50f 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -2,6 +2,7 @@ package xai import ( "errors" + "fmt" "io" "net/http" "strings" @@ -10,6 +11,7 @@ import ( "github.com/QuantumNous/new-api/relay/channel" "github.com/QuantumNous/new-api/relay/channel/openai" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/relay/constant" @@ -26,10 +28,20 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt return nil, errors.New("not implemented") } -func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { - //TODO implement me - //panic("implement me") - return nil, errors.New("not available") +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + openAIRequest, err := service.ClaudeToOpenAIRequest(*request, info) + if err != nil { + return nil, err + } + if info != nil && info.ChannelMeta != nil && info.SupportStreamOptions && info.IsStream { + openAIRequest.StreamOptions = &dto.StreamOptions{ + IncludeUsage: true, + } + } + return a.ConvertOpenAIRequest(c, info, openAIRequest) } func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { @@ -51,6 +63,11 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayFormat == types.RelayFormatClaude && + info.RelayMode != constant.RelayModeResponses && + info.RelayMode != constant.RelayModeResponsesCompact { + return fmt.Sprintf("%s/v1/chat/completions", strings.TrimRight(info.ChannelBaseUrl, "/")), nil + } return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil } diff --git a/relay/channel/xai/adaptor_test.go b/relay/channel/xai/adaptor_test.go new file mode 100644 index 000000000000..84e7631be3c6 --- /dev/null +++ b/relay/channel/xai/adaptor_test.go @@ -0,0 +1,160 @@ +package xai + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "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/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConvertClaudeRequestUsesOpenAICompatibleChatRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + stream := true + maxTokens := uint(128) + temperature := 0.2 + info := &relaycommon.RelayInfo{ + IsStream: true, + RelayFormat: types.RelayFormatClaude, + ChannelMeta: &relaycommon.ChannelMeta{ + SupportStreamOptions: true, + UpstreamModelName: "grok-4.3-fast", + }, + } + info.ClaudeConvertInfo = &relaycommon.ClaudeConvertInfo{ + LastMessagesType: relaycommon.LastMessageTypeNone, + } + + converted, err := (&Adaptor{}).ConvertClaudeRequest(c, info, &dto.ClaudeRequest{ + Model: "grok-4.3-fast", + MaxTokens: &maxTokens, + Stream: &stream, + Temperature: &temperature, + Messages: []dto.ClaudeMessage{ + { + Role: "user", + Content: "hello", + }, + }, + }) + + require.NoError(t, err) + openAIRequest, ok := converted.(*dto.GeneralOpenAIRequest) + require.True(t, ok) + assert.Equal(t, "grok-4.3-fast", openAIRequest.Model) + require.Len(t, openAIRequest.Messages, 1) + assert.Equal(t, "user", openAIRequest.Messages[0].Role) + assert.Equal(t, "hello", openAIRequest.Messages[0].StringContent()) + require.NotNil(t, openAIRequest.Stream) + assert.True(t, *openAIRequest.Stream) + require.NotNil(t, openAIRequest.StreamOptions) + assert.True(t, openAIRequest.StreamOptions.IncludeUsage) + require.NotNil(t, openAIRequest.MaxTokens) + assert.Equal(t, maxTokens, *openAIRequest.MaxTokens) + require.NotNil(t, openAIRequest.Temperature) + assert.Equal(t, temperature, *openAIRequest.Temperature) +} + +func TestGetRequestURLUsesChatCompletionsForClaudeFormat(t *testing.T) { + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatClaude, + RelayMode: relayconstant.RelayModeChatCompletions, + RequestURLPath: "/v1/messages", + ChannelMeta: &relaycommon.ChannelMeta{ + ChannelBaseUrl: "https://api.x.ai", + }, + } + + requestURL, err := (&Adaptor{}).GetRequestURL(info) + + require.NoError(t, err) + assert.Equal(t, "https://api.x.ai/v1/chat/completions", requestURL) +} + +func TestXAIHandlerConvertsClaudeFormatResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatClaude, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "grok-4.3-fast", + }, + ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{}, + } + responseBody := `{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"grok-4.3-fast","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}` + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(responseBody)), + } + + usage, newAPIError := xAIHandler(c, info, resp) + + require.Nil(t, newAPIError) + require.NotNil(t, usage) + assert.Equal(t, 3, usage.PromptTokens) + assert.Equal(t, 2, usage.CompletionTokens) + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Body.String(), `"type":"message"`) + assert.Contains(t, recorder.Body.String(), `"content":[{"type":"text","text":"pong"}]`) + assert.Contains(t, recorder.Body.String(), `"input_tokens":3`) +} + +func TestXAIStreamHandlerConvertsClaudeFormatResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + originalStreamingTimeout := constant.StreamingTimeout + constant.StreamingTimeout = 30 + t.Cleanup(func() { + constant.StreamingTimeout = originalStreamingTimeout + }) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + info := &relaycommon.RelayInfo{ + RelayFormat: types.RelayFormatClaude, + IsStream: true, + ChannelMeta: &relaycommon.ChannelMeta{ + UpstreamModelName: "grok-4.3-fast", + }, + ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{ + LastMessagesType: relaycommon.LastMessageTypeNone, + }, + } + info.SetEstimatePromptTokens(3) + responseBody := strings.Join([]string{ + `data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"grok-4.3-fast","choices":[{"index":0,"delta":{"content":"pong"},"finish_reason":null}],"usage":null}`, + "", + `data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1,"model":"grok-4.3-fast","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":0,"total_tokens":5}}`, + "", + "data: [DONE]", + "", + }, "\n") + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(responseBody)), + } + + usage, newAPIError := xAIStreamHandler(c, info, resp) + + require.Nil(t, newAPIError) + require.NotNil(t, usage) + assert.Equal(t, 3, usage.PromptTokens) + assert.Equal(t, 2, usage.CompletionTokens) + body := recorder.Body.String() + assert.Contains(t, body, "event: message_start") + assert.Contains(t, body, "event: content_block_delta") + assert.Contains(t, body, "event: message_stop") + assert.Contains(t, body, `"text":"pong"`) + assert.Contains(t, body, `"input_tokens":3`) + assert.NotContains(t, body, "[DONE]") +} diff --git a/relay/channel/xai/text.go b/relay/channel/xai/text.go index f9a8ee2e6f96..3449991527a5 100644 --- a/relay/channel/xai/text.go +++ b/relay/channel/xai/text.go @@ -40,6 +40,7 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re var responseTextBuilder strings.Builder var toolCount int var containStreamUsage bool + var lastStreamData string helper.SetEventStreamHeaders(c) @@ -60,8 +61,23 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re } openaiResponse := streamResponseXAI2OpenAI(xAIResp, usage) + if openaiResponse == nil { + return + } _ = openai.ProcessStreamResponse(*openaiResponse, &responseTextBuilder, &toolCount) - if err := helper.ObjectData(c, openaiResponse); err != nil { + openaiResponseData, err := common.Marshal(openaiResponse) + if err != nil { + common.SysLog(err.Error()) + sr.Error(err) + return + } + lastStreamData = string(openaiResponseData) + if info.RelayFormat == types.RelayFormatClaude { + err = openai.HandleStreamFormat(c, info, lastStreamData, false, false) + } else { + err = helper.ObjectData(c, openaiResponse) + } + if err != nil { common.SysLog(err.Error()) sr.Error(err) } @@ -72,7 +88,13 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re usage.CompletionTokens += toolCount * 7 } - helper.Done(c) + if info.RelayFormat == types.RelayFormatClaude { + if lastStreamData != "" { + openai.HandleFinalResponse(c, info, lastStreamData, "", 0, info.UpstreamModelName, "", usage, containStreamUsage) + } + } else { + helper.Done(c) + } service.CloseResponseBodyGracefully(resp) return usage, nil } @@ -94,13 +116,28 @@ func xAIHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response xaiResponse.Usage.CompletionTokenDetails.TextTokens = xaiResponse.Usage.CompletionTokens - xaiResponse.Usage.CompletionTokenDetails.ReasoningTokens } - // new body - encodeJson, err := common.Marshal(xaiResponse) + openAIResponse := dto.OpenAITextResponse{ + Id: xaiResponse.Id, + Object: xaiResponse.Object, + Created: xaiResponse.Created, + Model: xaiResponse.Model, + Choices: xaiResponse.Choices, + } + if xaiResponse.Usage != nil { + openAIResponse.Usage = *xaiResponse.Usage + } + + var responseObject any = xaiResponse + if info.RelayFormat == types.RelayFormatClaude { + responseObject = service.ResponseOpenAI2Claude(&openAIResponse, info) + } + + encodeJson, err := common.Marshal(responseObject) if err != nil { return nil, types.NewError(err, types.ErrorCodeBadResponseBody) } service.IOCopyBytesGracefully(c, resp, encodeJson) - return xaiResponse.Usage, nil + return &openAIResponse.Usage, nil }